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

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

import {NativeEventTarget as EventTarget} from 'chrome://resources/ash/common/event_target.js';
import {assert} from 'chrome://resources/js/assert.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {MockDirectoryEntry, MockEntry, MockFileSystem} from '../../common/js/mock_entry.js';
import {waitUntil} from '../../common/js/test_error_reporting.js';
import {VolumeType} from '../../common/js/volume_manager_types.js';

import type {DirectoryModel} from './directory_model.js';
import {FileListModel} from './file_list_model.js';
import {ListThumbnailLoader, ListThumbnailLoaderTask, TEST_VOLUME_TYPE, type ThumbnailLoadedEvent} from './list_thumbnail_loader.js';
import type {MetadataKey} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import type {ThumbnailModel} from './metadata/thumbnail_model.js';
import {MockThumbnailLoader} from './mock_thumbnail_loader.js';
import type {ThumbnailLoader} from './thumbnail_loader.js';

let currentVolumeType: string;
let listThumbnailLoader: ListThumbnailLoader;
let getCallbacks: Record<string, Function>;
let thumbnailLoadedEvents: ThumbnailLoadedEvent[];
let thumbnailModel: ThumbnailModel;
let metadataModel: MetadataModel;
let fileListModel: FileListModel;
let directoryModel: DirectoryModel;
let isScanningForTest: boolean;

const fileSystem = new MockFileSystem('volume-id');
const directory1 =
    MockDirectoryEntry.create(fileSystem, '/TestDirectory') as DirectoryEntry;

const entry1 = new MockEntry(fileSystem, '/Test1.jpg');
const entry2 = new MockEntry(fileSystem, '/Test2.jpg');
const entry3 = new MockEntry(fileSystem, '/Test3.jpg');
const entry4 = new MockEntry(fileSystem, '/Test4.jpg');
const entry5 = new MockEntry(fileSystem, '/Test5.jpg');
const entry6 = new MockEntry(fileSystem, '/Test6.jpg');

export function setUp() {
  currentVolumeType = TEST_VOLUME_TYPE;

  MockThumbnailLoader.errorUrls = [];
  MockThumbnailLoader.testImageWidth = 160;
  MockThumbnailLoader.testImageHeight = 160;

  // Create an image dataURL for testing.
  const canvas = document.createElement('canvas');
  canvas.width = MockThumbnailLoader.testImageWidth;
  canvas.height = MockThumbnailLoader.testImageHeight;
  const context = canvas.getContext('2d');
  if (!context) {
    throw new Error('Failed to get context from canvas');
  }
  context.fillStyle = 'black';
  context.fillRect(0, 0, 80, 80);
  context.fillRect(80, 80, 80, 80);
  const testImageDataUrl = canvas.toDataURL('image/jpeg', 0.5);

  MockThumbnailLoader.testImageDataUrl = testImageDataUrl;

  getCallbacks = {};

  thumbnailModel = {
    get: function(entries) {
      return new Promise(fulfill => {
        getCallbacks[getKeyOfGetCallback(entries)] = fulfill;
      });
    },
  } as ThumbnailModel;

  metadataModel = {
    get: (_entries: Array<Entry|FilesAppEntry>, _names: MetadataKey[]) => {},
    getCache: (_entries: Array<Entry|FilesAppEntry>, _names: MetadataKey[]) => {
      return [{}];
    },
  } as MetadataModel;

  fileListModel = new FileListModel(metadataModel);

  isScanningForTest = false;

  class TestDirectoryModel extends EventTarget {
    getFileList() {
      return fileListModel;
    }
    isScanning() {
      return isScanningForTest;
    }
  }

  directoryModel = new TestDirectoryModel() as DirectoryModel;

  const fakeVolumeManager = {
    getVolumeInfo: (_entry: Entry|FilesAppEntry) => {
      return {
        volumeType: currentVolumeType,
      };
    },
  } as VolumeManager;

  listThumbnailLoader = new ListThumbnailLoader(
      directoryModel, thumbnailModel, fakeVolumeManager,
      MockThumbnailLoader as unknown as typeof ThumbnailLoader, 5);
  listThumbnailLoader.numOfMaxActiveTasksForTest = 2;

  thumbnailLoadedEvents = [];
  listThumbnailLoader.addEventListener(
      'thumbnailLoaded', (event: ThumbnailLoadedEvent) => {
        thumbnailLoadedEvents.push(event);
      });
}

function getKeyOfGetCallback(entries: Array<Entry|FilesAppEntry>): string {
  return entries.reduce((previous, current) => {
    return previous + '|' + current.toURL();
  }, '');
}

function resolveGetLatestCallback(entries: Entry[]) {
  const key = getKeyOfGetCallback(entries);
  assert(getCallbacks[key]);
  getCallbacks[key]?.(entries.map(() => {
    return {thumbnail: {}};
  }));
  delete getCallbacks[key];
}

function hasPendingGetLatestCallback(entries: Entry[]) {
  return !!getCallbacks[getKeyOfGetCallback(entries)];
}

function areEntriesInCache(entries: Entry[]) {
  for (const entry of entries) {
    if (null === listThumbnailLoader.getThumbnailFromCache(entry)) {
      return false;
    }
  }
  return true;
}

/**
 * Story test for list thumbnail loader.
 */
export async function testStory() {
  fileListModel.push(directory1, entry1, entry2, entry3, entry4, entry5);

  // Set high priority range to 0 - 2.
  listThumbnailLoader.setHighPriorityRange(0, 2);

  // Assert that 2 fetch tasks are running.
  assertTrue(hasPendingGetLatestCallback([entry1]));
  assertTrue(hasPendingGetLatestCallback([entry2]));
  assertEquals(2, Object.keys(getCallbacks).length);

  // Fails to get thumbnail from cache for Test2.jpg.
  assertEquals(null, listThumbnailLoader.getThumbnailFromCache(entry2));

  // Set high priority range to 4 - 6.
  listThumbnailLoader.setHighPriorityRange(4, 6);

  // Assert that no new tasks are enqueued.
  assertTrue(hasPendingGetLatestCallback([entry1]));
  assertTrue(hasPendingGetLatestCallback([entry2]));
  assertEquals(2, Object.keys(getCallbacks).length);

  resolveGetLatestCallback([entry2]);

  // Assert that thumbnailLoaded event is fired for Test2.jpg.
  await waitUntil(() => thumbnailLoadedEvents.length === 1);

  const event = thumbnailLoadedEvents.shift()!;
  assertEquals('filesystem:volume-id/Test2.jpg', event.detail.fileUrl);
  assertTrue((event.detail.dataUrl?.length ?? -1) > 0);
  assertEquals(160, event.detail.width);
  assertEquals(160, event.detail.height);

  // Since thumbnail of Test2.jpg is loaded into the cache,
  // getThumbnailFromCache returns thumbnail for the image.
  const thumbnail = listThumbnailLoader.getThumbnailFromCache(entry2)!;
  assertEquals('filesystem:volume-id/Test2.jpg', thumbnail.fileUrl);
  assertTrue((thumbnail.dataUrl?.length ?? -1) > 0);
  assertEquals(160, thumbnail.width);
  assertEquals(160, thumbnail.height);

  // Assert that new task is enqueued.
  await waitUntil(() => {
    return hasPendingGetLatestCallback([entry1]) &&
        hasPendingGetLatestCallback([entry4]) &&
        Object.keys(getCallbacks).length === 2;
  });

  // Set high priority range to 2 - 4.
  listThumbnailLoader.setHighPriorityRange(2, 4);

  resolveGetLatestCallback([entry1]);

  // Assert that task for (Test3.jpg) is enqueued.
  await waitUntil(() => {
    return hasPendingGetLatestCallback([entry3]) &&
        hasPendingGetLatestCallback([entry4]) &&
        Object.keys(getCallbacks).length === 2;
  });
}

/**
 * Do not enqueue prefetch task when high priority range is at the end of list.
 */
export function testRangeIsAtTheEndOfList() {
  // Set high priority range to 5 - 6.
  listThumbnailLoader.setHighPriorityRange(5, 6);

  fileListModel.push(directory1, entry1, entry2, entry3, entry4, entry5);

  // Assert that a task is enqueued for entry5.
  assertTrue(hasPendingGetLatestCallback([entry5]));
  assertEquals(1, Object.keys(getCallbacks).length);
}

export async function testCache() {
  listThumbnailLoader.numOfMaxActiveTasksForTest = 5;

  // Set high priority range to 0 - 2.
  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(entry1, entry2, entry3, entry4, entry5, entry6);

  resolveGetLatestCallback([entry1]);
  // In this test case, entry 3 is resolved earlier than entry 2.
  resolveGetLatestCallback([entry3]);
  resolveGetLatestCallback([entry2]);
  assertEquals(0, Object.keys(getCallbacks).length);

  await waitUntil(() => {
    return areEntriesInCache([entry3, entry2, entry1]);
  });

  // Move high priority range to 1 - 3.
  listThumbnailLoader.setHighPriorityRange(1, 3);
  resolveGetLatestCallback([entry4]);
  assertEquals(0, Object.keys(getCallbacks).length);

  await waitUntil(() => {
    return areEntriesInCache([entry4, entry3, entry2, entry1]);
  });

  // Move high priority range to 4 - 6.
  listThumbnailLoader.setHighPriorityRange(4, 6);
  resolveGetLatestCallback([entry5]);
  resolveGetLatestCallback([entry6]);
  assertEquals(0, Object.keys(getCallbacks).length);

  await waitUntil(() => {
    return areEntriesInCache([entry6, entry5, entry4, entry3, entry2]);
  });

  // Move high priority range to 3 - 5.
  listThumbnailLoader.setHighPriorityRange(3, 5);
  assertEquals(0, Object.keys(getCallbacks).length);
  assertTrue(areEntriesInCache([entry6, entry5, entry4, entry3, entry2]));

  // Move high priority range to 0 - 2.
  listThumbnailLoader.setHighPriorityRange(0, 2);
  resolveGetLatestCallback([entry1]);
  assertEquals(0, Object.keys(getCallbacks).length);

  await waitUntil(() => {
    return areEntriesInCache([entry3, entry2, entry1, entry6, entry5]);
  });
}

/**
 * Test case for thumbnail fetch error. In this test case, thumbnail fetch for
 * entry 2 is failed.
 */
export async function testErrorHandling() {
  MockThumbnailLoader.errorUrls = [entry2.toURL()];

  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(entry1, entry2, entry3, entry4);

  resolveGetLatestCallback([entry2]);

  // Assert that new task is enqueued for entry3.
  await waitUntil(() => {
    return hasPendingGetLatestCallback([entry3]);
  });
}

/**
 * Test case for handling sorted event in data model.
 */
export async function testSortedEvent() {
  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(directory1, entry1, entry2, entry3, entry4, entry5);

  resolveGetLatestCallback([entry1]);
  resolveGetLatestCallback([entry2]);
  assertEquals(0, Object.keys(getCallbacks).length);

  // In order to assert that following task enqueues are fired by sorted event,
  // wait until all thumbnail loads are completed.
  await waitUntil(() => {
    return thumbnailLoadedEvents.length === 2;
  });

  // After the sort, list should be
  // directory1, entry5, entry4, entry3, entry2, entry1.
  fileListModel.sort('name', 'desc');

  await waitUntil(() => {
    return hasPendingGetLatestCallback([entry5]) &&
        hasPendingGetLatestCallback([entry4]);
  });
}

/**
 * Test case for handling change event in data model.
 */
export async function testChangeEvent() {
  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(directory1, entry1, entry2, entry3);

  resolveGetLatestCallback([entry1]);
  resolveGetLatestCallback([entry2]);
  assertEquals(0, Object.keys(getCallbacks).length);

  await waitUntil(() => {
    return thumbnailLoadedEvents.length === 2;
  });

  // entry1 is changed.
  const changeEvent = new CustomEvent('change', {detail: {index: 1}});
  fileListModel.dispatchEvent(changeEvent);

  // cache of entry1 should become invalid.
  const thumbnail = listThumbnailLoader.getThumbnailFromCache(entry1)!;
  assertTrue(thumbnail.outdated);

  resolveGetLatestCallback([entry1]);

  // Wait until thumbnailLoaded event is fired again for the change.
  await waitUntil(() => thumbnailLoadedEvents.length === 3);
}

/**
 * Test case for MTP volume.
 */
export function testMTPVolume() {
  currentVolumeType = VolumeType.MTP;

  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(directory1, entry1, entry2, entry3);

  // Only one request should be enqueued on MTP volume.
  assertEquals(1, Object.keys(getCallbacks).length);
}

/**
 * Test case that directory scan is running.
 */
export function testDirectoryScanIsRunning() {
  // Items are added during directory scan.
  isScanningForTest = true;

  listThumbnailLoader.setHighPriorityRange(0, 2);
  fileListModel.push(directory1, entry1, entry2);
  assertEquals(0, Object.keys(getCallbacks).length);

  // Scan completed after adding the last item.
  fileListModel.push(entry3);
  isScanningForTest = false;
  directoryModel.dispatchEvent(new CustomEvent('cur-dir-scan-completed'));

  assertEquals(2, Object.keys(getCallbacks).length);
}

/**
 * Test case for EXIF IO error and retrying logic.
 */
export async function testExifIOError() {
  const volumeManager = {
    getVolumeInfo: (_entry: Entry|FilesAppEntry) => {
      return {
        volumeType: currentVolumeType,
      };
    },
  } as VolumeManager;

  const thumbnailModel = {
    get: function(_entries: Array<Entry|FilesAppEntry>) {
      return Promise.resolve([{
        thumbnail: {
          urlError: {
            errorDescription: 'Error: Unexpected EOF @0',
          },
        },
      }]);
    },
  } as ThumbnailModel;

  const thumbnailLoaderConstructor = (() => {
                                       // Thumbnails should be fetched only from
                                       // EXIF on IO error.
                                       assertTrue(false);
                                     }) as unknown as typeof ThumbnailLoader;

  const task = new ListThumbnailLoaderTask(
      entry1, volumeManager, thumbnailModel, thumbnailLoaderConstructor);


  const thumbnailData = await task.fetch();
  assertEquals(null, thumbnailData.dataUrl);
  assertFalse(thumbnailData.outdated);
  await waitUntil(() => {
    return thumbnailData.outdated;
  });
}