/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.module.ModuleManagerTest');
goog.setTestOnly();

const BaseModule = goog.require('goog.module.BaseModule');
const MockClock = goog.require('goog.testing.MockClock');
const ModuleLoadFailure = goog.require('goog.module.ModuleLoadFailure');
const ModuleManager = goog.require('goog.module.ModuleManager');
const functions = goog.require('goog.functions');
const googArray = goog.require('goog.array');
const recordFunction = goog.require('goog.testing.recordFunction');
const testSuite = goog.require('goog.testing.testSuite');
const testing = goog.require('goog.testing');

let clock;
let requestCount = 0;

function getModuleManager(infoMap) {
  const mm = new ModuleManager();
  mm.setAllModuleInfo(infoMap);

  /**
   * @suppress {globalThis,checkTypes} suppression added to enable type
   * checking
   */
  mm.isModuleLoaded = function(id) {
    return this.getModuleInfo(id).isLoaded();
  };
  return mm;
}

function createSuccessfulBatchLoader(moduleMgr) {
  return {
    loadModules: /**
                    @suppress {globalThis} suppression added to enable type
                    checking
                  */
        function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
          requestCount++;
          setTimeout(goog.bind(this.onLoad, this, ids.concat(), 0), 5);
        },
    onLoad: /**
               @suppress {globalThis} suppression added to enable type checking
             */
        function(ids, idxLoaded) {
          moduleMgr.beforeLoadModuleCode(ids[idxLoaded]);
          moduleMgr.setLoaded();
          const idx = idxLoaded + 1;
          if (idx < ids.length) {
            setTimeout(goog.bind(this.onLoad, this, ids, idx), 2);
          }
        },
  };
}

function createSuccessfulNonBatchLoader(moduleMgr) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      requestCount++;
      setTimeout(() => {
        moduleMgr.beforeLoadModuleCode(ids[0]);
        moduleMgr.setLoaded();
        if (onSuccess) {
          onSuccess();
        }
      }, 5);
    },
  };
}

function createUnsuccessfulLoader(moduleMgr, status) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      moduleMgr.beforeLoadModuleCode(ids[0]);
      setTimeout(() => {
        onError(status);
      }, 5);
    },
  };
}

function createUnsuccessfulBatchLoader(moduleMgr, status) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      setTimeout(() => {
        onError(status);
      }, 5);
    },
  };
}

function createTimeoutLoader(moduleMgr, status) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      setTimeout(() => {
        onTimeout(status);
      }, 5);
    },
  };
}

/**
 * Tests execOnLoad with the specified module manager.
 * @param {ModuleManager} mm The module manager.
 * @suppress {missingProperties,checkTypes} suppression added to enable type
 * checking
 */
function execOnLoad(mm) {
  // When module is unloaded, execOnLoad is async.
  let execCalled1 = false;
  mm.execOnLoad('a', () => {
    execCalled1 = true;
  });
  assertFalse('module "a" should not be loaded', mm.isModuleLoaded('a'));
  assertTrue('module "a" should be loading', mm.isModuleLoading('a'));
  assertFalse('execCalled1 should not be set yet', execCalled1);
  assertTrue('ModuleManager should be active', mm.isActive());
  assertFalse('ModuleManager should not be user active', mm.isUserActive());
  clock.tick(5);
  assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
  assertFalse('module "a" should not be loading', mm.isModuleLoading('a'));
  assertTrue('execCalled1 should be set', execCalled1);
  assertFalse('ModuleManager should not be active', mm.isActive());
  assertFalse('ModuleManager should not be user active', mm.isUserActive());

  // When module is already loaded, execOnLoad is still async unless
  // specified otherwise.
  let execCalled2 = false;
  mm.execOnLoad('a', () => {
    execCalled2 = true;
  });
  assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
  assertFalse('module "a" should not be loading', mm.isModuleLoading('a'));
  assertFalse('execCalled2 should not be set yet', execCalled2);
  clock.tick(5);
  assertTrue('execCalled2 should be set', execCalled2);

  // When module is unloaded, execOnLoad is async (user active).
  let execCalled5 = false;
  mm.execOnLoad('c', () => {
    execCalled5 = true;
  }, null, null, true);
  assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));
  assertTrue('module "c" should be loading', mm.isModuleLoading('c'));
  assertFalse('execCalled1 should not be set yet', execCalled5);
  assertTrue('ModuleManager should be active', mm.isActive());
  assertTrue('ModuleManager should be user active', mm.isUserActive());
  clock.tick(5);
  assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));
  assertFalse('module "c" should not be loading', mm.isModuleLoading('c'));
  assertTrue('execCalled1 should be set', execCalled5);
  assertFalse('ModuleManager should not be active', mm.isActive());
  assertFalse('ModuleManager should not be user active', mm.isUserActive());

  // When module is already loaded, execOnLoad is still synchronous when
  // so specified
  let execCalled6 = false;
  mm.execOnLoad('c', () => {
    execCalled6 = true;
  }, undefined, undefined, undefined, true);
  assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));
  assertFalse('module "c" should not be loading', mm.isModuleLoading('c'));
  assertTrue('execCalled6 should be set', execCalled6);
  clock.tick(5);
  assertTrue('execCalled6 should still be set', execCalled6);
}

/**
 * Perform tests with the specified module manager.
 * @param {ModuleManager} mm The module manager.
 * @suppress {missingProperties} suppression added to enable type checking
 */
function execOnLoadWhilePreloadingAndViceVersa(mm) {
  mm = getModuleManager({'c': [], 'd': []});
  mm.setLoader(createSuccessfulNonBatchLoader(mm));

  const origBeforeLoadModuleCode = mm.beforeLoadModuleCode;
  const origSetLoaded = mm.setLoaded;
  const calls = [0, 0];
  mm.beforeLoadModuleCode = (id) => {
    calls[0]++;
    origBeforeLoadModuleCode.call(mm, id);
  };
  mm.setLoaded = () => {
    calls[1]++;
    origSetLoaded.call(mm);
  };

  mm.preloadModule('c', 2);
  assertFalse('module "c" should not be loading yet', mm.isModuleLoading('c'));
  clock.tick(2);
  assertTrue('module "c" should now be loading', mm.isModuleLoading('c'));
  mm.execOnLoad('c', () => {});
  assertTrue('module "c" should still be loading', mm.isModuleLoading('c'));
  clock.tick(5);
  assertFalse('module "c" should be done loading', mm.isModuleLoading('c'));
  assertEquals('beforeLoad should only be called once for "c"', 1, calls[0]);
  assertEquals('setLoaded should only be called once for "c"', 1, calls[1]);

  mm.execOnLoad('d', () => {});
  assertTrue('module "d" should now be loading', mm.isModuleLoading('d'));
  mm.preloadModule('d', 2);
  clock.tick(5);
  assertFalse('module "d" should be done loading', mm.isModuleLoading('d'));
  assertTrue('module "d" should now be loaded', mm.isModuleLoaded('d'));
  assertEquals('beforeLoad should only be called once for "d"', 2, calls[0]);
  assertEquals('setLoaded should only be called once for "d"', 2, calls[1]);
}

function assertDependencyOrder(list, mm) {
  const seen = {};
  for (let i = 0; i < list.length; i++) {
    const id = list[i];
    seen[id] = true;
    const deps = mm.getModuleInfo(id).getDependencies();
    for (let j = 0; j < deps.length; j++) {
      const dep = deps[j];
      assertTrue(
          `Unresolved dependency [${dep}] for [${id}].`,
          seen[dep] || mm.getModuleInfo(dep).isLoaded());
    }
  }
}

function createSuccessfulNonBatchLoaderWithRegisterInitCallback(moduleMgr, fn) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      moduleMgr.beforeLoadModuleCode(ids[0]);
      moduleMgr.registerInitializationCallback(fn);
      setTimeout(() => {
        moduleMgr.setLoaded();
        if (onSuccess) {
          onSuccess();
        }
      }, 5);
    },
  };
}

function createModulesFor(var_args) {
  const result = {};
  for (let i = 0; i < arguments.length; i++) {
    const key = arguments[i];
    result[key] = {ctor: BaseModule};
  }
  return result;
}

function createSuccessfulNonBatchLoaderWithConstructor(moduleMgr, info) {
  return {
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      setTimeout(() => {
        moduleMgr.beforeLoadModuleCode(ids[0]);
        moduleMgr.setModuleConstructor(info[ids[0]].ctor);
        moduleMgr.setLoaded();
        if (onSuccess) {
          onSuccess();
        }
      }, 5);
    },
  };
}

/**
 * Creates an AbstractModuleLoader implementation with extra edges support
 * @param {!Array} loaderCalls array to which the arguments of loadModules will
 *     be appended
 * @return {!Object<function(), boolean>}
 * @suppress {checkTypes} suppression added to enable type checking
 */
function createModuleLoaderWithExtraEdgesSupport(loaderCalls) {
  return {
    loadModules(ids, moduleInfoMap, loadOptions) {
      loaderCalls.push({
        ids: ids,
        moduleInfoMap: moduleInfoMap,
        ...loadOptions,
      });
    },
    supportsExtraEdges: true,
  };
}

/**
 * Creates an AbstractModuleLoader implementation that registers one
 * initialization callback for a synthetic module, then simulates loading the
 * given modules.
 * @param {!ModuleManager} moduleMgr
 * @param {!Array} modulesToMarkAsLoaded
 * @return {{loadModules: function(), syntheticModuleCallbackCalled: boolean}}
 * @suppress {checkTypes} suppression added to enable type checking
 */
function createExcludingSyntheticModuleOverheadLoader(
    moduleMgr, modulesToMarkAsLoaded) {
  return {
    syntheticModuleCallbackCalled: false,
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      const cb = () => {
        this.syntheticModuleCallbackCalled = true;
      };
      requestCount++;
      setTimeout(() => {
        // Simulate a synthetic module loading first, and registering a cb.
        moduleMgr.registerInitializationCallback(cb);
        for (const id of modulesToMarkAsLoaded) {
          moduleMgr.beforeLoadModuleCode(id);
          moduleMgr.setLoaded();
        }
        if (onSuccess) {
          onSuccess();
        }
      }, 5);
    },
  };
}

/**
 * Creates an AbstractModuleLoader implementation that registers one
 * initialization callback for a synthetic module, then attempts to set a
 * module constructor
 * @param {!ModuleManager} moduleMgr
 * @param {!Array} modulesToMarkAsLoaded
 * @return {{loadModules: function(), constructorSet: boolean}}
 */
function createExcludingSyntheticModuleOverheadLoaderWithConstructor(
    moduleMgr, modulesToMarkAsLoaded) {
  return {
    constructorSet: false,
    loadModules: function(ids, moduleInfoMap, {onError, onSuccess, onTimeout}) {
      requestCount++;
      setTimeout(() => {
        // Simulate a synthetic module loading first, and registering a cb.
        moduleMgr.registerInitializationCallback(() => {});
        class ModuleCtor extends BaseModule {
          constructor() {
            super();
            this.constructorSet = true;
          }
        }
        moduleMgr.setModuleConstructor(ModuleCtor);
        for (const id of modulesToMarkAsLoaded) {
          moduleMgr.beforeLoadModuleCode(id);
          moduleMgr.setLoaded();
        }
        if (onSuccess) {
          onSuccess();
        }
      }, 5);
    },
  };
}

testSuite({
  tearDown() {
    clock.dispose();
  },

  setUp() {
    clock = new MockClock(true);
    requestCount = 0;
  },

  /**
   * Tests loading a module under different conditions i.e. unloaded
   * module, already loaded module, module loaded through user initiated
   * actions, synchronous callback for a module that has been already
   * loaded. Test both batch and non-batch loaders.
   */
  testExecOnLoad() {
    let mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));
    execOnLoad(mm);

    mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulBatchLoader(mm));
    mm.setBatchModeEnabled(true);
    execOnLoad(mm);
  },

  /**
     Test aborting the callback called on module load.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testExecOnLoadAbort() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    // When module is unloaded and abort is called, module still gets
    // loaded, but callback is cancelled.
    let execCalled1 = false;
    const callback1 = mm.execOnLoad('b', () => {
      execCalled1 = true;
    });
    callback1.abort();
    clock.tick(5);
    assertTrue('module "b" should be loaded', mm.isModuleLoaded('b'));
    assertFalse('execCalled3 should not be set', execCalled1);

    // When module is already loaded, execOnLoad is still async, so calling
    // abort should still cancel the callback.
    let execCalled2 = false;
    const callback2 = mm.execOnLoad('a', () => {
      execCalled2 = true;
    });
    callback2.abort();
    clock.tick(5);
    assertFalse('execCalled2 should not be set', execCalled2);
  },

  /**
   * Test preloading modules and ensure that the before load, after load
   * and set load called are called only once per module.
   */
  testExecOnLoadWhilePreloadingAndViceVersa() {
    let mm = getModuleManager({'c': [], 'd': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));
    execOnLoadWhilePreloadingAndViceVersa(mm);

    mm = getModuleManager({'c': [], 'd': []});
    mm.setLoader(createSuccessfulBatchLoader(mm));
    mm.setBatchModeEnabled(true);
    execOnLoadWhilePreloadingAndViceVersa(mm);
  },

  /**
   * Tests that multiple callbacks on the same module don't cause
   * confusion about the active state after the module is finally loaded.
   */
  testUserInitiatedExecOnLoadEventuallyLeavesManagerIdle() {
    const mm = getModuleManager({'c': [], 'd': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack1 = false;
    let calledBack2 = false;

    mm.execOnLoad('c', () => {
      calledBack1 = true;
    }, undefined, undefined, true);
    mm.execOnLoad('c', () => {
      calledBack2 = true;
    }, undefined, undefined, true);
    mm.load('c');

    assertTrue(
        'Manager should be active while waiting for load', mm.isUserActive());

    clock.tick(5);

    assertTrue('First callback should be called', calledBack1);
    assertTrue('Second callback should be called', calledBack2);
    assertFalse(
        'Manager should be inactive after loading is complete',
        mm.isUserActive());
  },

  /** Tests loading a module by requesting a Deferred object. */
  testLoad() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack = false;
    let error = null;

    const d = mm.load('a');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertFalse(calledBack);
    assertNull(error);
    assertFalse(mm.isUserActive());

    clock.tick(5);

    assertTrue(calledBack);
    assertNull(error);
  },

  /**
   * Tests loading 2 modules asserting that the loads happen in parallel
   * in one unit of time.
   */
  testLoad_concurrent() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setConcurrentLoadingEnabled(true);
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    const calledBack = false;
    const error = null;

    mm.load('a');
    mm.load('b');
    assertEquals(2, requestCount);
    // Only time for one serialized download.
    clock.tick(5);

    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertTrue(mm.getModuleInfo('b').isLoaded());
  },

  testLoad_concurrentSecondIsDepOfFist() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setBatchModeEnabled(true);
    mm.setConcurrentLoadingEnabled(true);
    mm.setLoader(createSuccessfulBatchLoader(mm));

    const calledBack = false;
    const error = null;

    mm.loadMultiple(['a', 'b']);
    mm.load('b');
    assertEquals('No 2nd request expected', 1, requestCount);
    // Only time for one serialized download.
    clock.tick(5);
    clock.tick(2);  // Makes second module come in from batch requst.

    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertTrue(mm.getModuleInfo('b').isLoaded());
  },

  testLoad_nonConcurrent() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    const calledBack = false;
    const error = null;

    mm.load('a');
    mm.load('b');
    assertEquals(1, requestCount);
    // Only time for one serialized download.
    clock.tick(5);

    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertFalse(mm.getModuleInfo('b').isLoaded());
  },

  testLoadUnknown() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));
    const e = assertThrows(() => {
      mm.load('DoesNotExist');
    });
    assertEquals('Unknown module: DoesNotExist', e.message);
  },

  /**
     Tests loading multiple modules by requesting a Deferred object.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadMultiple() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setBatchModeEnabled(true);
    mm.setLoader(createSuccessfulBatchLoader(mm));

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;

    const dMap = mm.loadMultiple(['a', 'b']);
    dMap['a'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });

    assertFalse(calledBack);
    assertFalse(calledBack2);

    clock.tick(5);
    assertTrue(calledBack);
    assertFalse(calledBack2);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(2);

    assertTrue(calledBack);
    assertTrue(calledBack2);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertTrue('module "b" should be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));
    assertNull(error);
    assertNull(error2);
  },

  /**
   * Tests loading multiple modules with deps by requesting a Deferred
   *      object.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadMultipleWithDeps() {
    const mm = getModuleManager({'a': [], 'b': ['c'], 'c': []});
    mm.setBatchModeEnabled(true);
    mm.setLoader(createSuccessfulBatchLoader(mm));

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;

    const dMap = mm.loadMultiple(['a', 'b']);
    dMap['a'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });

    assertFalse(calledBack);
    assertFalse(calledBack2);

    clock.tick(5);
    assertTrue(calledBack);
    assertFalse(calledBack2);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(2);

    assertFalse(calledBack2);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));

    clock.tick(2);

    assertTrue(calledBack2);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertTrue('module "b" should be loaded', mm.isModuleLoaded('b'));
    assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));
    assertNull(error);
    assertNull(error2);
  },

  /**
   * Tests loading multiple modules by requesting a Deferred object when
   * a server error occurs.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadMultipleWithErrors() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setBatchModeEnabled(true);
    mm.setLoader(createUnsuccessfulLoader(mm, 500));

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;
    let calledBack3 = false;
    let error3 = null;

    const dMap = mm.loadMultiple(['a', 'b', 'c']);
    dMap['a'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });
    dMap['c'].then(
        (ctx) => {
          calledBack3 = true;
        },
        (err) => {
          error3 = err;
        });

    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);

    clock.tick(4);

    // A module request is now underway using the unsuccessful loader.
    // We substitute a successful loader for future module load requests.
    mm.setLoader(createSuccessfulBatchLoader(mm));

    clock.tick(1);

    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertFalse('module "a" should not be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    // Retry should happen after a backoff
    clock.tick(5 + mm.getBackOff_());

    assertTrue(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(2);
    assertTrue(calledBack2);
    assertFalse(calledBack3);
    assertTrue('module "b" should be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(2);
    assertTrue(calledBack3);
    assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));

    assertNull(error);
    assertNull(error2);
    assertNull(error3);
  },

  /**
   * Tests loading multiple modules by requesting a Deferred object when
   * consecutive server error occur and the loader falls back to serial
   * loads.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadMultipleWithErrorsFallbackOnSerial() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setBatchModeEnabled(true);
    mm.setLoader(createUnsuccessfulLoader(mm, 500));

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;
    let calledBack3 = false;
    let error3 = null;

    const dMap = mm.loadMultiple(['a', 'b', 'c']);
    dMap['a'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });
    dMap['c'].then(
        (ctx) => {
          calledBack3 = true;
        },
        (err) => {
          error3 = err;
        });

    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);

    clock.tick(5);

    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertFalse('module "a" should not be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    // Retry should happen and fail after a backoff
    clock.tick(5 + mm.getBackOff_());
    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertFalse('module "a" should not be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    // A second retry should happen after a backoff
    clock.tick(4 + mm.getBackOff_());
    // The second retry is now underway using the unsuccessful loader.
    // We substitute a successful loader for future module load requests.
    mm.setLoader(createSuccessfulBatchLoader(mm));

    clock.tick(1);

    // A second retry should fail now
    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertFalse('module "a" should not be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    // Each module should be loaded individually now, each taking 5 ticks

    clock.tick(5);
    assertTrue(calledBack);
    assertFalse(calledBack2);
    assertFalse(calledBack3);
    assertTrue('module "a" should be loaded', mm.isModuleLoaded('a'));
    assertFalse('module "b" should not be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(5);
    assertTrue(calledBack2);
    assertFalse(calledBack3);
    assertTrue('module "b" should be loaded', mm.isModuleLoaded('b'));
    assertFalse('module "c" should not be loaded', mm.isModuleLoaded('c'));

    clock.tick(5);
    assertTrue(calledBack3);
    assertTrue('module "c" should be loaded', mm.isModuleLoaded('c'));

    assertNull(error);
    assertNull(error2);
    assertNull(error3);
  },

  /**
     Tests loading a module by user action by requesting a Deferred object.
   */
  testLoadForUser() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack = false;
    let error = null;

    const d = mm.load('a', true);
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertFalse(calledBack);
    assertNull(error);
    assertTrue(mm.isUserActive());

    clock.tick(5);

    assertTrue(calledBack);
    assertNull(error);
  },

  /**
   * Test loading modules that include synthetic modules that omit their
   * calls to beforeLoadModuleCode() and setLoaded().
   */
  testLoadWithoutSyntheticModuleOverhead() {
    const mm = getModuleManager({'a': []});
    const loader = createExcludingSyntheticModuleOverheadLoader(
        mm, /* modulesToMarkAsLoaded= */['a']);
    mm.setLoader(loader);

    let calledBack = false;
    let error = null;

    const d = mm.load('a');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertFalse(calledBack);
    assertNull(error);
    assertFalse(mm.isUserActive());
    assertFalse(loader.syntheticModuleCallbackCalled);
    assertFalse(mm.getModuleInfo('a').isLoaded());

    clock.tick(5);

    assertTrue(calledBack);
    assertNull(error);
    assertTrue(loader.syntheticModuleCallbackCalled);
    assertTrue(mm.getModuleInfo('a').isLoaded());
  },

  /**
   * Testing setModuleConstructor fails for synthetic modules
   */
  testSetModuleConstructorFailsForSyntheticModules() {
    const mm = getModuleManager({'a': []});
    const loader = createExcludingSyntheticModuleOverheadLoaderWithConstructor(
        mm, /* modulesToMarkAsLoaded= */['a']);
    mm.setLoader(loader);
    mm.load('a');
    assertFalse(mm.getModuleInfo('a').isLoaded());

    clock.tick(5);

    assertFalse(loader.constructorSet);
    assertTrue(mm.getModuleInfo('a').isLoaded());
  },

  /**
   * Same as testLoadWithoutSyntheticModuleOverhead, but this time we load
   * module info to simulate positive module loading, where the manager is aware
   * of synthetic modules.
   */
  testLoadWithoutSyntheticModuleOverhead_MarksSyntheticModulesAsLoaded() {
    const mm = getModuleManager({'sy0': [], 'a': [], 'b': ['sy0', 'a']});
    const loader = createExcludingSyntheticModuleOverheadLoader(
        mm, /* modulesToMarkAsLoaded= */['a', 'b']);
    mm.setLoader(loader);

    let calledBack = false;
    let error = null;

    const d = mm.load('a');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertFalse(calledBack);
    assertNull(error);
    assertFalse(mm.isUserActive());
    assertFalse(loader.syntheticModuleCallbackCalled);
    assertFalse(mm.getModuleInfo('sy0').isLoaded());
    assertFalse(mm.getModuleInfo('a').isLoaded());
    assertFalse(mm.getModuleInfo('b').isLoaded());

    clock.tick(5);

    assertTrue(calledBack);
    assertNull(error);
    assertTrue(loader.syntheticModuleCallbackCalled);
    assertTrue(mm.getModuleInfo('sy0').isLoaded());
    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertTrue(mm.getModuleInfo('b').isLoaded());
  },

  /**
   * Test loading modules that include synthetic modules that omit their
   * calls to beforeLoadModuleCode() and setLoaded().
   */
  testLoadWithoutSyntheticModuleOverheadSetsSyntheticModuleDepsAsLoaded() {
    const mm =
        getModuleManager({'sy0': [], 'sy1': [], 'a': [], 'b': ['sy0', 'a']});
    mm.setAllModuleInfoString('', ['sy0', 'a']);
    const loader = createExcludingSyntheticModuleOverheadLoader(
        mm, /* modulesToMarkAsLoaded= */[]);
    mm.setLoader(loader);
    mm.beforeLoadModuleCode('b');

    // Since b has a dep on sy0 and a, both of them should be marked as loaded
    // and removed from this.loadingModuleIds_. This should make mm.isActive()
    // return false as this.loadingModuleIds_ will be empty. However, b should
    // still remain unloaded (isLoaded() == false).
    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertTrue(mm.getModuleInfo('sy0').isLoaded());
    assertFalse(mm.isActive());
    assertFalse(mm.getModuleInfo('b').isLoaded());
  },

  testExtraEdges() {
    const mm =
        getModuleManager({'modA': [], 'modB': [], 'modC': [], 'modD': []});
    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));
    mm.addExtraEdge('modA', 'modB');
    mm.addExtraEdge('modA', 'modC');
    mm.addExtraEdge('modC', 'modD');

    const expectedExtraEdges = {
      'modA': {'modB': true, 'modC': true},
      'modC': {'modD': true},
    };

    mm.load('modA');
    assertEquals(1, loaderCalls.length);
    assertObjectEquals(expectedExtraEdges, loaderCalls[0].extraEdges);
  },

  testAddExtraEdge_managerDoesNotSupportExtraEdges() {
    const mm =
        getModuleManager({'modA': [], 'modB': [], 'modC': [], 'modD': []});
    mm.setLoader({
      loadModules(ids, moduleInfoMap, loadOptions) {},
    });
    mm.addExtraEdge('modA', 'modB');
    assertThrows(() => mm.load('modA'));
  },

  testAddExtraEdge_loadedFromModuleLoadsToModule() {
    const mm = getModuleManager({'modA': [], 'modB': []});

    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));

    // Set modA as loaded.
    mm.beforeLoadModuleCode('modA');
    mm.setLoaded('modA');

    mm.addExtraEdge('modA', 'modB');
    assertEquals(1, loaderCalls.length);
    assertObjectEquals(['modB'], loaderCalls[0].ids);
  },

  testSetLoaded_extraEdgeFromAlreadyRequestedModuleLoadsMissingModule() {
    const mm = getModuleManager({'modA': [], 'modB': [], 'modC': []});

    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));

    mm.beforeLoadModuleCode('modA');
    mm.addExtraEdge('modB', 'modC');
    mm.setLoaded('modA');

    mm.beforeLoadModuleCode('modB');
    mm.setLoaded('modB');

    assertEquals(1, loaderCalls.length);
    assertObjectEquals(['modC'], loaderCalls[0].ids);
  },

  testRemoveExtraEdge() {
    const mm =
        getModuleManager({'modA': [], 'modB': [], 'modC': [], 'modD': []});
    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));
    mm.addExtraEdge('modA', 'modB');
    mm.addExtraEdge('modA', 'modC');
    mm.addExtraEdge('modC', 'modD');
    mm.removeExtraEdge('modA', 'modB');

    const expectedExtraEdges = {
      'modA': {'modC': true},
      'modC': {'modD': true},
    };

    mm.load('modA');
    assertEquals(1, loaderCalls.length);
    assertObjectEquals(expectedExtraEdges, loaderCalls[0].extraEdges);
  },

  testRemoveEdge_nonexistentEdge() {
    const mm =
        getModuleManager({'modA': [], 'modB': [], 'modC': [], 'modD': []});
    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));
    mm.addExtraEdge('modA', 'modC');
    mm.addExtraEdge('modC', 'modD');
    mm.removeExtraEdge('modA', 'modB');

    const expectedExtraEdges = {
      'modA': {'modC': true},
      'modC': {'modD': true},
    };

    mm.load('modA');
    assertEquals(1, loaderCalls.length);
    assertObjectEquals(expectedExtraEdges, loaderCalls[0].extraEdges);
  },

  testRemoveEdge_allEdgesRemoved() {
    const mm =
        getModuleManager({'modA': [], 'modB': [], 'modC': [], 'modD': []});
    const loaderCalls = [];
    mm.setLoader(createModuleLoaderWithExtraEdgesSupport(loaderCalls));
    mm.addExtraEdge('modA', 'modC');
    mm.addExtraEdge('modC', 'modD');
    mm.removeExtraEdge('modA', 'modC');

    const expectedExtraEdges = {
      'modC': {'modD': true},
    };

    mm.load('modA');
    assertEquals(1, loaderCalls.length);
    assertObjectEquals(expectedExtraEdges, loaderCalls[0].extraEdges);
  },

  /** Tests that preloading a module calls back the deferred object. */
  testPreloadDeferredWhenNotLoaded() {
    const mm = getModuleManager({'a': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack = false;

    const d = mm.preloadModule('a');
    d.addCallback((ctx) => {
      calledBack = true;
    });

    // First load should take five ticks.
    assertFalse('module "a" should not be loaded yet', calledBack);
    clock.tick(5);
    assertTrue('module "a" should be loaded', calledBack);
  },

  /** Tests preloading an already loaded module. */
  testPreloadDeferredWhenLoaded() {
    const mm = getModuleManager({'a': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack = false;

    mm.preloadModule('a');
    clock.tick(5);

    const d = mm.preloadModule('a');
    d.addCallback((ctx) => {
      calledBack = true;
    });

    // Module is already loaded, should be called back after the setTimeout
    // in preloadModule.
    assertFalse('deferred for module "a" should not be called yet', calledBack);
    clock.tick(1);
    assertTrue('module "a" should be loaded', calledBack);
  },

  /** Tests preloading a module that is currently loading. */
  testPreloadDeferredWhenLoading() {
    const mm = getModuleManager({'a': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    mm.preloadModule('a');
    clock.tick(1);

    // 'b' is in the middle of loading, should get called back when it's
    // done.
    let calledBack = false;
    const d = mm.preloadModule('a');
    d.addCallback((ctx) => {
      calledBack = true;
    });

    assertFalse('module "a" should not be loaded yet', calledBack);
    clock.tick(4);
    assertTrue('module "a" should be loaded', calledBack);
  },

  /**
   * Tests that load doesn't trigger another load if a module is already
   * preloading.
   */
  testLoadWhenPreloading() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    const origBeforeLoadModuleCode = mm.beforeLoadModuleCode;
    const origSetLoaded = mm.setLoaded;
    const calls = [0, 0];
    mm.beforeLoadModuleCode = (id) => {
      calls[0]++;
      origBeforeLoadModuleCode.call(mm, id);
    };
    mm.setLoaded = () => {
      calls[1]++;
      origSetLoaded.call(mm);
    };

    let calledBack = false;
    let error = null;

    mm.preloadModule('c', 2);
    assertFalse(
        'module "c" should not be loading yet', mm.isModuleLoading('c'));
    clock.tick(2);
    assertTrue('module "c" should now be loading', mm.isModuleLoading('c'));

    const d = mm.load('c');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertTrue('module "c" should still be loading', mm.isModuleLoading('c'));
    clock.tick(5);
    assertFalse('module "c" should be done loading', mm.isModuleLoading('c'));
    assertEquals('beforeLoad should only be called once for "c"', 1, calls[0]);
    assertEquals('setLoaded should only be called once for "c"', 1, calls[1]);

    assertTrue(calledBack);
    assertNull(error);
  },

  /**
   * Tests that load doesn't trigger another load if a module is already
   * preloading.
   */
  testLoadMultipleWhenPreloading() {
    const mm = getModuleManager({'a': [], 'b': ['d'], 'c': [], 'd': []});
    mm.setLoader(createSuccessfulBatchLoader(mm));
    mm.setBatchModeEnabled(true);

    const origBeforeLoadModuleCode = mm.beforeLoadModuleCode;
    const origSetLoaded = mm.setLoaded;
    const calls = {'a': 0, 'b': 0, 'c': 0, 'd': 0};
    mm.beforeLoadModuleCode = (id) => {
      calls[id]++;
      origBeforeLoadModuleCode.call(mm, id);
    };
    let setLoadedCalls = 0;
    mm.setLoaded = () => {
      setLoadedCalls++;
      origSetLoaded.call(mm);
    };

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;
    let calledBack3 = false;
    let error3 = null;

    mm.preloadModule('c', 2);
    mm.preloadModule('d', 3);
    assertFalse(
        'module "c" should not be loading yet', mm.isModuleLoading('c'));
    assertFalse(
        'module "d" should not be loading yet', mm.isModuleLoading('d'));
    clock.tick(2);
    assertTrue('module "c" should now be loading', mm.isModuleLoading('c'));
    clock.tick(1);
    assertTrue('module "d" should now be loading', mm.isModuleLoading('d'));

    const dMap = mm.loadMultiple(['a', 'b', 'c']);
    dMap['a'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });
    dMap['c'].then(
        (ctx) => {
          calledBack3 = true;
        },
        (err) => {
          error3 = err;
        });

    assertTrue('module "a" should be loading', mm.isModuleLoading('a'));
    assertTrue('module "b" should be loading', mm.isModuleLoading('b'));
    assertTrue('module "c" should still be loading', mm.isModuleLoading('c'));
    clock.tick(4);
    assertTrue(calledBack3);

    assertFalse('module "c" should be done loading', mm.isModuleLoading('c'));
    assertTrue('module "d" should still be loading', mm.isModuleLoading('d'));
    clock.tick(5);
    assertFalse('module "d" should be done loading', mm.isModuleLoading('d'));

    assertFalse(calledBack);
    assertFalse(calledBack2);
    assertTrue('module "a" should still be loading', mm.isModuleLoading('a'));
    assertTrue('module "b" should still be loading', mm.isModuleLoading('b'));
    clock.tick(7);

    assertTrue(calledBack);
    assertTrue(calledBack2);
    assertFalse('module "a" should be done loading', mm.isModuleLoading('a'));
    assertFalse('module "b" should be done loading', mm.isModuleLoading('b'));

    assertEquals(
        'beforeLoad should only be called once for "a"', 1, calls['a']);
    assertEquals(
        'beforeLoad should only be called once for "b"', 1, calls['b']);
    assertEquals(
        'beforeLoad should only be called once for "c"', 1, calls['c']);
    assertEquals(
        'beforeLoad should only be called once for "d"', 1, calls['d']);
    assertEquals(
        'setLoaded should have been called 4 times', 4, setLoadedCalls);

    assertNull(error);
    assertNull(error2);
    assertNull(error3);
  },

  /**
   * Tests that the deferred is still called when loadMultiple loads modules
   * that are already preloading.
   */
  testLoadMultipleWhenPreloadingSameModules() {
    const mm = getModuleManager({'a': [], 'b': ['d'], 'c': [], 'd': []});
    mm.setLoader(createSuccessfulBatchLoader(mm));
    mm.setBatchModeEnabled(true);

    const origBeforeLoadModuleCode = mm.beforeLoadModuleCode;
    const origSetLoaded = mm.setLoaded;
    const calls = {'c': 0, 'd': 0};
    mm.beforeLoadModuleCode = (id) => {
      calls[id]++;
      origBeforeLoadModuleCode.call(mm, id);
    };
    let setLoadedCalls = 0;
    mm.setLoaded = () => {
      setLoadedCalls++;
      origSetLoaded.call(mm);
    };

    let calledBack = false;
    let error = null;
    let calledBack2 = false;
    let error2 = null;

    mm.preloadModule('c', 2);
    mm.preloadModule('d', 3);
    assertFalse(
        'module "c" should not be loading yet', mm.isModuleLoading('c'));
    assertFalse(
        'module "d" should not be loading yet', mm.isModuleLoading('d'));
    clock.tick(2);
    assertTrue('module "c" should now be loading', mm.isModuleLoading('c'));
    clock.tick(1);
    assertTrue('module "d" should now be loading', mm.isModuleLoading('d'));

    const dMap = mm.loadMultiple(['c', 'd']);
    dMap['c'].then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });
    dMap['d'].then(
        (ctx) => {
          calledBack2 = true;
        },
        (err) => {
          error2 = err;
        });

    assertTrue('module "c" should still be loading', mm.isModuleLoading('c'));
    clock.tick(4);
    assertFalse('module "c" should be done loading', mm.isModuleLoading('c'));
    assertTrue('module "d" should still be loading', mm.isModuleLoading('d'));
    clock.tick(5);
    assertFalse('module "d" should be done loading', mm.isModuleLoading('d'));

    assertTrue(calledBack);
    assertTrue(calledBack2);

    assertEquals(
        'beforeLoad should only be called once for "c"', 1, calls['c']);
    assertEquals(
        'beforeLoad should only be called once for "d"', 1, calls['d']);
    assertEquals('setLoaded should have been called twice', 2, setLoadedCalls);

    assertNull(error);
    assertNull(error2);
  },

  /**
   * Tests loading a module via load when the module is already
   * loaded.  The deferred's callback should be called immediately.
   */
  testLoadWhenLoaded() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    let calledBack = false;
    let error = null;

    mm.preloadModule('b', 2);
    clock.tick(10);

    assertFalse('module "b" should be done loading', mm.isModuleLoading('b'));

    const d = mm.load('b');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    clock.tick(1);
    assertTrue(calledBack);
    assertNull(error);
  },

  /**
     Tests that the deferred's errbacks are called if the module fails to
     load.
   */
  testLoadWithFailingModule() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createUnsuccessfulLoader(mm, 401));
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.UNAUTHORIZED, cause.type);
          assertEquals('Failure status was not as expected', 401, cause.status);
        });
    let calledBack = false;
    let error = null;

    const d = mm.load('a');
    d.then(
        (ctx) => {
          calledBack = true;
        },
        (err) => {
          error = err;
        });

    assertFalse(calledBack);
    assertNull(error);

    clock.tick(500);

    assertFalse(calledBack);

    // NOTE: Deferred always calls errbacks with an Error object.  The
    // failure type enum is present as error.failureType, while the error
    // message is human readable and contains the module id.
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, error.failureType.type);
    assertEquals(
        'Failure status was not as expected', 401, error.failureType.status);
    assertEquals(
        'Error message was not as expected',
        'Error loading a: Unauthorized (401)', error.message);
  },

  /**
     Tests that the deferred's errbacks are called if a module fails to
     load.
   */
  testLoadMultipleWithFailingModule() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(createUnsuccessfulLoader(mm, 401));
    mm.setBatchModeEnabled(true);
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.UNAUTHORIZED, cause.type);
        });
    let calledBack11 = false;
    let error11 = null;
    let calledBack12 = false;
    let error12 = null;
    let calledBack21 = false;
    let error21 = null;
    let calledBack22 = false;
    let error22 = null;

    const dMap = mm.loadMultiple(['a', 'b']);
    dMap['a'].then(
        (ctx) => {
          calledBack11 = true;
        },
        (err) => {
          error11 = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack12 = true;
        },
        (err) => {
          error12 = err;
        });

    const dMap2 = mm.loadMultiple(['b', 'c']);
    dMap2['b'].then(
        (ctx) => {
          calledBack21 = true;
        },
        (err) => {
          error21 = err;
        });
    dMap2['c'].then(
        (ctx) => {
          calledBack22 = true;
        },
        (err) => {
          error22 = err;
        });

    assertFalse(calledBack11);
    assertFalse(calledBack12);
    assertFalse(calledBack21);
    assertFalse(calledBack22);
    assertNull(error11);
    assertNull(error12);
    assertNull(error21);
    assertNull(error22);

    clock.tick(5);

    assertFalse(calledBack11);
    assertFalse(calledBack12);
    assertFalse(calledBack21);
    assertFalse(calledBack22);

    // NOTE: Deferred always calls errbacks with an Error object.  The
    // failure type enum is present as error.failureType, while the error
    // message is human readable and contains the module id.
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, error11.failureType.type);
    assertEquals(
        'Error message was not as expected',
        'Error loading a: Unauthorized (401)', error11.message);
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, error12.failureType.type);
    assertEquals(
        'Error message was not as expected',
        'Error loading b: Unauthorized (401)', error12.message);

    // The first deferred of the second load should be called since it asks
    // for one of the failed modules.
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, Number(error21.failureType.type));
    assertEquals(
        'Error message was not as expected',
        'Error loading b: Unauthorized (401)', error21.message);

    // The last deferred should be dropped so it is neither called back nor
    // an error.
    assertFalse(calledBack22);
    assertNull(error22);
  },

  /**
     Tests that the right dependencies are cancelled on a loadMultiple
     failure.
   */
  testLoadMultipleWithFailingModuleDependencies() {
    const mm =
        getModuleManager({'a': [], 'b': [], 'c': ['b'], 'd': ['c'], 'e': []});
    mm.setLoader(createUnsuccessfulLoader(mm, 401));
    mm.setBatchModeEnabled(true);
    const cancelledIds = [];

    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.UNAUTHORIZED, cause.type);
          cancelledIds.push(id);
        });
    let calledBack11 = false;
    let error11 = null;
    let calledBack12 = false;
    let error12 = null;
    let calledBack21 = false;
    let error21 = null;
    let calledBack22 = false;
    let error22 = null;
    let calledBack23 = false;
    let error23 = null;

    const dMap = mm.loadMultiple(['a', 'b']);
    dMap['a'].then(
        (ctx) => {
          calledBack11 = true;
        },
        (err) => {
          error11 = err;
        });
    dMap['b'].then(
        (ctx) => {
          calledBack12 = true;
        },
        (err) => {
          error12 = err;
        });

    const dMap2 = mm.loadMultiple(['c', 'd', 'e']);
    dMap2['c'].then(
        (ctx) => {
          calledBack21 = true;
        },
        (err) => {
          error21 = err;
        });
    dMap2['d'].then(
        (ctx) => {
          calledBack22 = true;
        },
        (err) => {
          error22 = err;
        });
    dMap2['e'].then(
        (ctx) => {
          calledBack23 = true;
        },
        (err) => {
          error23 = err;
        });

    assertFalse(calledBack11);
    assertFalse(calledBack12);
    assertFalse(calledBack21);
    assertFalse(calledBack22);
    assertFalse(calledBack23);
    assertNull(error11);
    assertNull(error12);
    assertNull(error21);
    assertNull(error22);
    assertNull(error23);

    clock.tick(5);

    assertFalse(calledBack11);
    assertFalse(calledBack12);
    assertFalse(calledBack21);
    assertFalse(calledBack22);
    assertFalse(calledBack23);

    // NOTE: Deferred always calls errbacks with an Error object.  The
    // failure type enum is present as error.failureType, while the error
    // message is human readable and contains the module id.
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, error11.failureType.type);
    assertEquals(
        'Error message was not as expected',
        'Error loading a: Unauthorized (401)', error11.message);
    assertEquals(
        'Failure cause was not as expected',
        ModuleLoadFailure.Type.UNAUTHORIZED, error12.failureType.type);
    assertEquals(
        'Error message was not as expected',
        'Error loading b: Unauthorized (401)', error12.message);

    // Check that among the failed modules, 'c' and 'd' are also cancelled
    // due to dependencies.
    assertTrue(googArray.equals(['a', 'b', 'c', 'd'], cancelledIds.sort()));
  },

  /**
   * Tests that when loading multiple modules, the input array is not
   * modified when it has duplicates.
   */
  testLoadMultipleWithDuplicates() {
    const mm = getModuleManager({'a': [], 'b': []});
    mm.setBatchModeEnabled(true);
    mm.setLoader(createSuccessfulBatchLoader(mm));

    const listWithDuplicates = ['a', 'a', 'b'];
    mm.loadMultiple(listWithDuplicates);
    assertArrayEquals(
        'loadMultiple should not modify its input', ['a', 'a', 'b'],
        listWithDuplicates);
  },

  /**
   * Test loading dependencies transitively.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadingDepsInNonBatchMode1() {
    const mm =
        getModuleManager({'i': [], 'j': [], 'k': ['j'], 'l': ['i', 'j', 'k']});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    mm.preloadModule('j');
    clock.tick(5);
    assertTrue('module "j" should be loaded', mm.isModuleLoaded('j'));
    assertFalse('module "i" should not be loaded (1)', mm.isModuleLoaded('i'));
    assertFalse('module "k" should not be loaded (1)', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (1)', mm.isModuleLoaded('l'));

    // When loading a module in non-batch mode, its dependencies should be
    // requested independently, and in dependency order.
    mm.preloadModule('l');
    clock.tick(5);
    assertTrue('module "i" should be loaded', mm.isModuleLoaded('i'));
    assertFalse('module "k" should not be loaded (2)', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (2)', mm.isModuleLoaded('l'));
    clock.tick(5);
    assertTrue('module "k" should be loaded', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (3)', mm.isModuleLoaded('l'));
    clock.tick(5);
    assertTrue('module "l" should be loaded', mm.isModuleLoaded('l'));
  },

  /**
     Test loading dependencies transitively and in dependency order.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testLoadingDepsInNonBatchMode2() {
    const mm = getModuleManager({
      'h': [],
      'i': ['h'],
      'j': ['i'],
      'k': ['j'],
      'l': ['i', 'j', 'k'],
      'm': ['l'],
    });
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    // When loading a module in non-batch mode, its dependencies should be
    // requested independently, and in dependency order. The order in this
    // case should be h,i,j,k,l,m.
    mm.preloadModule('m');
    clock.tick(5);
    assertTrue('module "h" should be loaded', mm.isModuleLoaded('h'));
    assertFalse('module "i" should not be loaded (1)', mm.isModuleLoaded('i'));
    assertFalse('module "j" should not be loaded (1)', mm.isModuleLoaded('j'));
    assertFalse('module "k" should not be loaded (1)', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (1)', mm.isModuleLoaded('l'));
    assertFalse('module "m" should not be loaded (1)', mm.isModuleLoaded('m'));

    clock.tick(5);
    assertTrue('module "i" should be loaded', mm.isModuleLoaded('i'));
    assertFalse('module "j" should not be loaded (2)', mm.isModuleLoaded('j'));
    assertFalse('module "k" should not be loaded (2)', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (2)', mm.isModuleLoaded('l'));
    assertFalse('module "m" should not be loaded (2)', mm.isModuleLoaded('m'));

    clock.tick(5);
    assertTrue('module "j" should be loaded', mm.isModuleLoaded('j'));
    assertFalse('module "k" should not be loaded (3)', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (3)', mm.isModuleLoaded('l'));
    assertFalse('module "m" should not be loaded (3)', mm.isModuleLoaded('m'));

    clock.tick(5);
    assertTrue('module "k" should be loaded', mm.isModuleLoaded('k'));
    assertFalse('module "l" should not be loaded (4)', mm.isModuleLoaded('l'));
    assertFalse('module "m" should not be loaded (4)', mm.isModuleLoaded('m'));

    clock.tick(5);
    assertTrue('module "l" should be loaded', mm.isModuleLoaded('l'));
    assertFalse('module "m" should not be loaded (5)', mm.isModuleLoaded('m'));

    clock.tick(5);
    assertTrue('module "m" should be loaded', mm.isModuleLoaded('m'));
  },

  /**
     @suppress {missingProperties} suppression added to enable type
     checking
   */
  testLoadingDepsInBatchMode() {
    const mm =
        getModuleManager({'e': [], 'f': [], 'g': ['f'], 'h': ['e', 'f', 'g']});
    mm.setLoader(createSuccessfulBatchLoader(mm));
    mm.setBatchModeEnabled(true);

    mm.preloadModule('f');
    clock.tick(5);
    assertTrue('module "f" should be loaded', mm.isModuleLoaded('f'));
    assertFalse('module "e" should not be loaded (1)', mm.isModuleLoaded('e'));
    assertFalse('module "g" should not be loaded (1)', mm.isModuleLoaded('g'));
    assertFalse('module "h" should not be loaded (1)', mm.isModuleLoaded('h'));

    // When loading a module in batch mode, its not-yet-loaded dependencies
    // should be requested at the same time, and in dependency order.
    mm.preloadModule('h');
    clock.tick(5);
    assertTrue('module "e" should be loaded', mm.isModuleLoaded('e'));
    assertFalse('module "g" should not be loaded (2)', mm.isModuleLoaded('g'));
    assertFalse('module "h" should not be loaded (2)', mm.isModuleLoaded('h'));
    clock.tick(2);
    assertTrue('module "g" should be loaded', mm.isModuleLoaded('g'));
    assertFalse('module "h" should not be loaded (3)', mm.isModuleLoaded('h'));
    clock.tick(2);
    assertTrue('module "h" should be loaded', mm.isModuleLoaded('h'));
  },

  /**
     Test unauthorized errors while loading modules.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testUnauthorizedLoading() {
    const mm = getModuleManager({'m': [], 'n': [], 'o': ['n']});
    mm.setLoader(createUnsuccessfulLoader(mm, 401));

    // Callback checks for an unauthorized error
    let firedLoadFailed = false;
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.UNAUTHORIZED, cause.type);
          firedLoadFailed = true;
        });
    mm.execOnLoad('o', () => {});
    assertTrue('module "o" should be loading', mm.isModuleLoading('o'));
    assertTrue('module "n" should be loading', mm.isModuleLoading('n'));
    clock.tick(5);
    assertTrue(
        'should have called unauthorized module callback', firedLoadFailed);
    assertFalse('module "o" should not be loaded', mm.isModuleLoaded('o'));
    assertFalse('module "o" should not be loading', mm.isModuleLoading('o'));
    assertFalse('module "n" should not be loaded', mm.isModuleLoaded('n'));
    assertFalse('module "n" should not be loading', mm.isModuleLoading('n'));
  },

  /**
     Test error loading modules which are retried.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testErrorLoadingModule() {
    const mm = getModuleManager({'p': ['q'], 'q': [], 'r': ['q', 'p']});
    mm.setLoader(createUnsuccessfulLoader(mm, 500));

    mm.preloadModule('r');
    clock.tick(4);

    // A module request is now underway using the unsuccessful loader.
    // We substitute a successful loader for future module load requests.
    mm.setLoader(createSuccessfulNonBatchLoader(mm));
    clock.tick(1);
    assertFalse('module "q" should not be loaded (1)', mm.isModuleLoaded('q'));
    assertFalse('module "p" should not be loaded (1)', mm.isModuleLoaded('p'));
    assertFalse('module "r" should not be loaded (1)', mm.isModuleLoaded('r'));

    // Failed loads are automatically retried after a backOff.
    clock.tick(5 + mm.getBackOff_());
    assertTrue('module "q" should be loaded', mm.isModuleLoaded('q'));
    assertFalse('module "p" should not be loaded (2)', mm.isModuleLoaded('p'));
    assertFalse('module "r" should not be loaded (2)', mm.isModuleLoaded('r'));

    // A successful load decrements the backOff.
    clock.tick(5);
    assertTrue('module "p" should be loaded', mm.isModuleLoaded('p'));
    assertFalse('module "r" should not be loaded (3)', mm.isModuleLoaded('r'));
    clock.tick(5);
    assertTrue('module "r" should be loaded', mm.isModuleLoaded('r'));
  },

  /**
     Tests error loading modules which are retried.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testErrorLoadingModule_batchMode() {
    const mm = getModuleManager({'p': ['q'], 'q': [], 'r': ['q', 'p']});
    mm.setLoader(createUnsuccessfulBatchLoader(mm, 500));
    mm.setBatchModeEnabled(true);

    mm.preloadModule('r');
    clock.tick(4);

    // A module request is now underway using the unsuccessful loader.
    // We substitute a successful loader for future module load requests.
    mm.setLoader(createSuccessfulBatchLoader(mm));
    clock.tick(1);
    assertFalse('module "q" should not be loaded (1)', mm.isModuleLoaded('q'));
    assertFalse('module "p" should not be loaded (1)', mm.isModuleLoaded('p'));
    assertFalse('module "r" should not be loaded (1)', mm.isModuleLoaded('r'));

    // Failed loads are automatically retried after a backOff.
    clock.tick(5 + mm.getBackOff_());
    assertTrue('module "q" should be loaded', mm.isModuleLoaded('q'));
    clock.tick(2);
    assertTrue('module "p" should not be loaded (2)', mm.isModuleLoaded('p'));
    clock.tick(2);
    assertTrue('module "r" should not be loaded (2)', mm.isModuleLoaded('r'));
  },

  /**
     Test consecutive errors in loading modules.
     @suppress {missingProperties} suppression added to enable type checking
   */
  testConsecutiveErrors() {
    const mm = getModuleManager({'s': []});
    mm.setLoader(createUnsuccessfulLoader(mm, 500));

    // Register an error callback for consecutive failures.
    let firedLoadFailed = false;
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.CONSECUTIVE_FAILURES, cause.type);
          firedLoadFailed = true;
        });

    mm.preloadModule('s');
    assertFalse('module "s" should not be loaded (0)', mm.isModuleLoaded('s'));

    // Fail twice.
    for (let i = 0; i < 2; i++) {
      clock.tick(5 + mm.getBackOff_());
      assertFalse(
          'module "s" should not be loaded (1)', mm.isModuleLoaded('s'));
      assertFalse('should not fire failed callback (1)', firedLoadFailed);
    }

    // Fail a third time and check that the callback is fired.
    clock.tick(5 + mm.getBackOff_());
    assertFalse('module "s" should not be loaded (2)', mm.isModuleLoaded('s'));
    assertTrue('should have fired failed callback', firedLoadFailed);

    // Check that it doesn't attempt to load the module anymore after it has
    // failed.
    let triedLoad = false;
    mm.setLoader({
      loadModules: function(ids, moduleInfoMap, {onError, onSuccess}) {
        triedLoad = true;
      },
    });

    // Also reset the failed callback flag and make sure it isn't called
    // again.
    firedLoadFailed = false;
    clock.tick(10 + mm.getBackOff_());
    assertFalse('module "s" should not be loaded (3)', mm.isModuleLoaded('s'));
    assertFalse('No more loads should have been tried', triedLoad);
    assertFalse(
        'The load failed callback should be fired only once', firedLoadFailed);
  },

  /**
   * Test loading errors due to old code.
   * @suppress {missingProperties} suppression added to enable type checking
   */
  testOldCodeGoneError() {
    const mm = getModuleManager({'s': []});
    mm.setLoader(createUnsuccessfulLoader(mm, 410));

    // Callback checks for an old code failure
    let firedLoadFailed = false;
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.OLD_CODE_GONE, cause.type);
          firedLoadFailed = true;
        });

    mm.preloadModule('s', 0);
    assertFalse('module "s" should not be loaded (0)', mm.isModuleLoaded('s'));
    clock.tick(5);
    assertFalse('module "s" should not be loaded (1)', mm.isModuleLoaded('s'));
    assertTrue('should have called old code gone callback', firedLoadFailed);
  },

  /**
   * Test timeout.
   * @suppress {missingProperties,checkTypes} suppression
   *      added to enable type checking
   */
  testTimeout() {
    const mm = getModuleManager({'s': []});
    mm.setLoader(createTimeoutLoader(mm, undefined));

    // Callback checks for timeout
    let firedTimeout = false;
    mm.registerCallback(
        ModuleManager.CallbackType.ERROR, (callbackType, id, cause) => {
          assertEquals(
              'Failure cause was not as expected',
              ModuleLoadFailure.Type.TIMEOUT, cause.type);
          firedTimeout = true;
        });

    mm.preloadModule('s', 0);
    assertFalse('module "s" should not be loaded (0)', mm.isModuleLoaded('s'));
    clock.tick(5);
    assertFalse('module "s" should not be loaded (1)', mm.isModuleLoaded('s'));
    assertTrue('should have called timeout callback', firedTimeout);
  },

  /**
   * Tests that an error during execOnLoad will trigger the error callback.
   * @suppress {checkTypes} suppression added to enable type checking
   */
  testExecOnLoadError() {
    // Expect two callbacks, each of which will be called with callback type
    // ERROR, the right module id and failure type INIT_ERROR.
    const errorCallback1 = testing.createFunctionMock('callback1');
    errorCallback1(
        ModuleManager.CallbackType.ERROR, 'b',
        new ModuleLoadFailure(ModuleLoadFailure.Type.INIT_ERROR));

    const errorCallback2 = testing.createFunctionMock('callback2');
    errorCallback2(
        ModuleManager.CallbackType.ERROR, 'b',
        new ModuleLoadFailure(ModuleLoadFailure.Type.INIT_ERROR));

    errorCallback1.$replay();
    errorCallback2.$replay();

    const mm = new ModuleManager();
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    // Register the first callback before setting the module info map.
    mm.registerCallback(ModuleManager.CallbackType.ERROR, errorCallback1);

    mm.setAllModuleInfo({'a': [], 'b': [], 'c': []});

    // Register the second callback after setting the module info map.
    mm.registerCallback(ModuleManager.CallbackType.ERROR, errorCallback2);

    let execOnLoadBCalled = false;
    mm.execOnLoad('b', () => {
      execOnLoadBCalled = true;
      throw new Error();
    });

    assertThrows(() => {
      clock.tick(5);
    });

    assertTrue(
        'execOnLoad should have been called on module b.', execOnLoadBCalled);
    errorCallback1.$verify();
    errorCallback2.$verify();
  },

  /**
   * Tests that an error during execOnLoad will trigger the error callback.
   * Uses setAllModuleInfoString rather than setAllModuleInfo.
   * @suppress {checkTypes} suppression added to enable type checking
   */
  testExecOnLoadErrorModuleInfoString() {
    // Expect a callback to be called with callback type ERROR, the right
    // module id and failure type INIT_ERROR.
    const errorCallback = testing.createFunctionMock('callback');
    errorCallback(
        ModuleManager.CallbackType.ERROR, 'b',
        new ModuleLoadFailure(ModuleLoadFailure.Type.INIT_ERROR));

    errorCallback.$replay();

    const mm = new ModuleManager();
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    // Register the first callback before setting the module info map.
    mm.registerCallback(ModuleManager.CallbackType.ERROR, errorCallback);

    mm.setAllModuleInfoString('a/b/c');

    let execOnLoadBCalled = false;
    mm.execOnLoad('b', () => {
      execOnLoadBCalled = true;
      throw new Error();
    });

    assertThrows(() => {
      clock.tick(5);
    });

    assertTrue(
        'execOnLoad should have been called on module b.', execOnLoadBCalled);
    errorCallback.$verify();
  },

  /** Make sure ModuleInfo objects in moduleInfoMap_ get disposed. */
  testDispose() {
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});

    const moduleInfoA = mm.getModuleInfo('a');
    assertNotNull(moduleInfoA);
    const moduleInfoB = mm.getModuleInfo('b');
    assertNotNull(moduleInfoB);
    const moduleInfoC = mm.getModuleInfo('c');
    assertNotNull(moduleInfoC);

    mm.dispose();
    assertTrue(moduleInfoA.isDisposed());
    assertTrue(moduleInfoB.isDisposed());
    assertTrue(moduleInfoC.isDisposed());
  },

  testDependencyOrderingWithSimpleDeps() {
    const mm = getModuleManager({
      'a': ['b', 'c'],
      'b': ['d'],
      'c': ['e', 'f'],
      'd': [],
      'e': [],
      'f': [],
    });
    const ids = mm.getNotYetLoadedTransitiveDepIds_('a');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['d', 'e', 'f', 'b', 'c', 'a'], ids);
  },

  testDependencyOrderingWithRequestedDep() {
    const mm = getModuleManager({
      'a': ['b', 'c'],
      'b': ['d'],
      'c': ['e', 'f'],
      'd': [],
      'e': [],
      'f': [],
    });
    mm.requestedModuleIds_ = ['a', 'b'];
    const ids = mm.getNotYetLoadedTransitiveDepIds_('a');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['e', 'f', 'c'], ids);
  },

  testDependencyOrderingWithCommonDepsInDeps() {
    // Tests to make sure that if dependencies of the root are loaded before
    // their common dependencies.
    const mm =
        getModuleManager({'a': ['b', 'c'], 'b': ['d'], 'c': ['d'], 'd': []});
    const ids = mm.getNotYetLoadedTransitiveDepIds_('a');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['d', 'b', 'c', 'a'], ids);
  },

  testDependencyOrderingWithCommonDepsInRoot1() {
    // Tests the case where a dependency of the root depends on another
    // dependency of the root.  Regardless of ordering in the root's
    // deps.
    const mm = getModuleManager({'a': ['b', 'c'], 'b': ['c'], 'c': []});
    const ids = mm.getNotYetLoadedTransitiveDepIds_('a');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['c', 'b', 'a'], ids);
  },

  testDependencyOrderingWithCommonDepsInRoot2() {
    // Tests the case where a dependency of the root depends on another
    // dependency of the root.  Regardless of ordering in the root's
    // deps.
    const mm = getModuleManager({'a': ['b', 'c'], 'b': [], 'c': ['b']});
    const ids = mm.getNotYetLoadedTransitiveDepIds_('a');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['b', 'c', 'a'], ids);
  },

  testDependencyOrderingWithGmailExample() {
    // Real dependency graph taken from gmail.
    const mm = getModuleManager({
      's': ['dp', 'ml', 'md'],
      'dp': ['a'],
      'ml': ['ld', 'm'],
      'ld': ['a'],
      'm': ['ad', 'mh', 'n'],
      'md': ['mh', 'ld'],
      'a': [],
      'mh': [],
      'ad': [],
      'n': [],
    });

    mm.beforeLoadModuleCode('a');
    mm.setLoaded();
    mm.beforeLoadModuleCode('m');
    mm.setLoaded();
    mm.beforeLoadModuleCode('n');
    mm.setLoaded();
    mm.beforeLoadModuleCode('ad');
    mm.setLoaded();
    mm.beforeLoadModuleCode('mh');
    mm.setLoaded();

    const ids = mm.getNotYetLoadedTransitiveDepIds_('s');
    assertDependencyOrder(ids, mm);
    assertArrayEquals(['ld', 'dp', 'ml', 'md', 's'], ids);
  },

  testRegisterInitializationCallback() {
    let initCalled = 0;
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    mm.setLoader(
        createSuccessfulNonBatchLoaderWithRegisterInitCallback(mm, () => {
          ++initCalled;
        }));
    execOnLoad(mm);
    // execOnLoad_ loads modules a and c
    assertTrue(initCalled == 2);
  },

  testSetModuleConstructor() {
    const initCalled = 0;
    const mm = getModuleManager({'a': [], 'b': [], 'c': []});
    const info = {
      'a': {ctor: AModule, count: 0},
      'b': {ctor: BModule, count: 0},
      'c': {ctor: CModule, count: 0},
    };
    function AModule() {
      ++info['a'].count;
      BaseModule.call(this);
    }
    goog.inherits(AModule, BaseModule);
    function BModule() {
      ++info['b'].count;
      BaseModule.call(this);
    }
    goog.inherits(BModule, BaseModule);
    function CModule() {
      ++info['c'].count;
      BaseModule.call(this);
    }
    goog.inherits(CModule, BaseModule);

    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(mm, info));
    execOnLoad(mm);
    assertTrue(info['a'].count == 1);
    assertTrue(info['b'].count == 0);
    assertTrue(info['c'].count == 1);
    assertTrue(mm.getModuleInfo('a').getModule() instanceof AModule);
    assertTrue(mm.getModuleInfo('c').getModule() instanceof CModule);
  },

  /**
   * Tests that a call to load the loading module during module
   * initialization doesn't trigger a second load.
   */
  testLoadWhenInitializing() {
    const mm = getModuleManager({'a': []});
    mm.setLoader(createSuccessfulNonBatchLoader(mm));

    const info = {'a': {ctor: AModule, count: 0}};
    function AModule() {
      ++info['a'].count;
      BaseModule.call(this);
    }
    goog.inherits(AModule, BaseModule);
    AModule.prototype.initialize = () => {
      mm.load('a');
    };
    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(mm, info));
    mm.preloadModule('a');
    clock.tick(5);
    assertEquals(info['a'].count, 1);
  },

  testErrorInEarlyCallback() {
    const errback = recordFunction();
    const callback = recordFunction();
    const mm = getModuleManager({'a': [], 'b': ['a']});
    mm.getModuleInfo('a').registerEarlyCallback(functions.error('error'));
    mm.getModuleInfo('a').registerCallback(callback);
    mm.getModuleInfo('a').registerErrback(errback);

    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(
        mm, createModulesFor('a', 'b')));
    mm.preloadModule('b');
    const e = assertThrows(() => {
      clock.tick(5);
    });

    assertEquals('error', e.message);
    assertEquals(0, callback.getCallCount());
    assertEquals(1, errback.getCallCount());
    assertEquals(
        ModuleLoadFailure.Type.INIT_ERROR,
        errback.getLastCall().getArguments()[0].type);
    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertFalse(mm.getModuleInfo('b').isLoaded());

    clock.tick(5);
    assertTrue(mm.getModuleInfo('b').isLoaded());
  },

  testErrorInNormalCallback() {
    const earlyCallback = recordFunction();
    const errback = recordFunction();
    const mm = getModuleManager({'a': [], 'b': ['a']});
    mm.getModuleInfo('a').registerEarlyCallback(earlyCallback);
    mm.getModuleInfo('a').registerCallback(functions.error('error'));
    mm.getModuleInfo('a').registerErrback(errback);

    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(
        mm, createModulesFor('a', 'b')));
    mm.preloadModule('b');
    const e = assertThrows(() => {
      clock.tick(10);
    });
    clock.tick(10);

    assertEquals('error', e.message);
    assertEquals(1, errback.getCallCount());
    assertEquals(
        ModuleLoadFailure.Type.INIT_ERROR,
        errback.getLastCall().getArguments()[0].type);
    assertTrue(mm.getModuleInfo('a').isLoaded());
    assertTrue(mm.getModuleInfo('b').isLoaded());
  },

  testErrorInErrback() {
    const mm = getModuleManager({'a': [], 'b': ['a']});
    mm.getModuleInfo('a').registerCallback(functions.error('error1'));
    mm.getModuleInfo('a').registerErrback(functions.error('error2'));

    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(
        mm, createModulesFor('a', 'b')));
    mm.preloadModule('a');
    let e = assertThrows(() => {
      clock.tick(10);
    });
    assertEquals('error1', e.message);
    e = assertThrows(() => {
      clock.tick(10);
    });
    assertEquals('error2', e.message);
    assertTrue(mm.getModuleInfo('a').isLoaded());
  },

  testInitCallbackInBaseModule() {
    let mm = new ModuleManager();
    let called = false;
    let context;
    mm.registerInitializationCallback((mcontext) => {
      called = true;
      context = mcontext;
    });
    mm.setAllModuleInfo({'a': [], 'b': ['a']});
    assertTrue('Base initialization not called', called);
    assertNull('Context should still be null', context);

    mm = new ModuleManager();
    called = false;
    mm.registerInitializationCallback((mcontext) => {
      called = true;
      context = mcontext;
    });
    const appContext = {};
    mm.setModuleContext(appContext);
    assertTrue('Base initialization not called after setModuleContext', called);
    assertEquals('Did not receive module context', appContext, context);
  },

  testSetAllModuleInfo() {
    const callback = recordFunction();
    const errback = recordFunction();
    const moduleInfo = {'base': [], 'one': ['base'], 'two': ['one']};
    const mm = getModuleManager(moduleInfo);
    mm.getModuleInfo('one').registerEarlyCallback(callback);
    mm.getModuleInfo('one').registerCallback(functions.error('error'));
    mm.getModuleInfo('one').registerErrback(errback);
    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(
        mm, createModulesFor('base', 'one', 'two')));
    mm.preloadModule('base');
    clock.tick(10);
    // Module 'base' is now loaded.
    assertTrue(mm.getModuleInfo('base').isLoaded());
    // Re-init all modules using same instance.
    mm.setAllModuleInfo(moduleInfo);
    // Re-init all modules using new instance.
    mm.setAllModuleInfo({'base': [], 'one': ['base'], 'two': ['one']});
    // Module 'base' is still loaded.
    assertTrue(mm.getModuleInfo('base').isLoaded());

    // Callbacks are still registered.
    mm.preloadModule('two');
    assertThrows(() => {
      clock.tick(10);
    });
    clock.tick(10);

    assertEquals(1, callback.getCallCount());
    assertEquals(1, errback.getCallCount());
  },

  testSetAllModuleInfoString() {
    const callback = recordFunction();
    const errback = recordFunction();
    const moduleInfo = {'base': [], 'one': ['base'], 'two': ['one']};
    const mm = getModuleManager(moduleInfo);
    mm.getModuleInfo('one').registerEarlyCallback(callback);
    mm.getModuleInfo('one').registerCallback(functions.error('error'));
    mm.getModuleInfo('one').registerErrback(errback);
    mm.setLoader(createSuccessfulNonBatchLoaderWithConstructor(
        mm, createModulesFor('base', 'one', 'two')));
    mm.preloadModule('base');
    clock.tick(10);
    // Module 'base' is now loaded.
    assertTrue(mm.getModuleInfo('base').isLoaded());
    // Re-init all modules using same instance.
    mm.setAllModuleInfoString('base/one:0/two:1/three:0,1,2/four:0,3/five:');
    // Module 'base' is still loaded.
    assertTrue(mm.getModuleInfo('base').isLoaded());

    assertNotNull('Base should exist', mm.getModuleInfo('base'));
    assertNotNull('One should exist', mm.getModuleInfo('one'));
    assertNotNull('Two should exist', mm.getModuleInfo('two'));
    assertNotNull('Three should exist', mm.getModuleInfo('three'));
    assertNotNull('Four should exist', mm.getModuleInfo('four'));
    assertNotNull('Five should exist', mm.getModuleInfo('five'));

    assertArrayEquals(
        ['base', 'one', 'two'], mm.getModuleInfo('three').getDependencies());
    assertArrayEquals(
        ['base', 'three'], mm.getModuleInfo('four').getDependencies());
    assertArrayEquals([], mm.getModuleInfo('five').getDependencies());

    // Callbacks are still registered.
    mm.preloadModule('two');
    assertThrows(() => {
      clock.tick(10);
    });
    clock.tick(10);

    assertEquals(1, callback.getCallCount());
    assertEquals(1, errback.getCallCount());
  },

  testSetAllModuleInfoStringWithEmptyString() {
    const mm = new ModuleManager();
    let called = false;
    let context;
    mm.registerInitializationCallback((mcontext) => {
      called = true;
      context = mcontext;
    });
    mm.setAllModuleInfoString('');
    assertTrue('Initialization not called', called);
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testBackOffAmounts() {
    const mm = new ModuleManager();
    assertEquals(0, mm.getBackOff_());

    mm.consecutiveFailures_++;
    assertEquals(5000, mm.getBackOff_());

    mm.consecutiveFailures_++;
    assertEquals(20000, mm.getBackOff_());
  },

  /**
   * Tests that the IDLE callbacks are executed for active->idle transitions
   * after setAllModuleInfoString with currently loading modules.
   */
  testIdleCallbackWithInitialModules() {
    const callback = recordFunction();

    const mm = new ModuleManager();
    mm.setAllModuleInfoString('a', ['a']);
    mm.registerCallback(ModuleManager.CallbackType.IDLE, callback);

    assertTrue(mm.isActive());

    mm.beforeLoadModuleCode('a');

    assertEquals(0, callback.getCallCount());

    mm.setLoaded();

    assertFalse(mm.isActive());

    assertEquals(1, callback.getCallCount());
  },
});
