Cancel scans when directory changed.

TEST=*ImportController* and *MediaScanner*

Review URL: https://codereview.chromium.org/1045663002

Cr-Commit-Position: refs/heads/master@{#323184}
diff --git a/ui/file_manager/file_manager/background/js/media_scanner.js b/ui/file_manager/file_manager/background/js/media_scanner.js
index 02e411e..4c20d2b3 100644
--- a/ui/file_manager/file_manager/background/js/media_scanner.js
+++ b/ui/file_manager/file_manager/background/js/media_scanner.js
@@ -103,10 +103,12 @@
 /** @override */
 importer.DefaultMediaScanner.prototype.scanDirectory = function(directory) {
   var scan = this.createScanResult_();
+  console.info(scan.name + ': Scanning directory ' + directory.fullPath);
+
   var watcher = this.watcherFactory_(
       /** @this {importer.DefaultMediaScanner} */
       function() {
-        scan.invalidateScan();
+        scan.cancel();
         this.notify_(importer.ScanEvent.INVALIDATED, scan);
       }.bind(this));
 
@@ -119,6 +121,7 @@
       .then(
           /** @this {importer.DefaultMediaScanner} */
           function() {
+            console.info(scan.name + ': Finished.');
             this.notify_(importer.ScanEvent.FINALIZED, scan);
           }.bind(this));
 
@@ -131,10 +134,14 @@
     throw new Error('Cannot scan empty list.');
   }
   var scan = this.createScanResult_();
+  console.info(
+      scan.name + ': Scanning fixed set of ' +
+      entries.length + ' entries.');
+
   var watcher = this.watcherFactory_(
       /** @this {importer.DefaultMediaScanner} */
       function() {
-        scan.invalidateScan();
+        scan.cancel();
         this.notify_(importer.ScanEvent.INVALIDATED, scan);
       }.bind(this));
 
@@ -148,6 +155,7 @@
       .then(
           /** @this {importer.DefaultMediaScanner} */
           function() {
+            console.info(scan.name + ': Finished.');
             this.notify_(importer.ScanEvent.FINALIZED, scan);
           }.bind(this));
 
@@ -160,7 +168,8 @@
 /**
  * @param {!importer.DefaultScanResult} scan
  * @param  {!Array<!FileEntry>} entries
- * @return {!Promise} Resolves when scanning is finished.
+ * @return {!Promise} Resolves when scanning is finished normally
+ *     or canceled.
  * @private
  */
 importer.DefaultMediaScanner.prototype.scanMediaFiles_ =
@@ -172,20 +181,32 @@
    *     to process.
    * @return {!Promise}
    */
-  var scanChunk = function(begin) {
+  var scanBatch = function(begin) {
+    if (scan.canceled()) {
+      console.debug(
+          scan.name + ': Skipping remaining ' +
+          (entries.length - begin) +
+          ' entries. Scan was canceled.');
+      return Promise.resolve();
+    }
+
     // the second arg to slice is an exclusive end index, so we +1 batch size.
-    var end = begin + importer.DefaultMediaScanner.SCAN_BATCH_SIZE + 1;
+    var end = begin + importer.DefaultMediaScanner.SCAN_BATCH_SIZE;
+    console.log(scan.name + ': Processing batch ' + begin + '-' + (end - 1));
+    var batch = entries.slice(begin, end);
+
     return Promise.all(
-        entries.slice(begin, end).map(handleFileEntry))
+        batch.map(handleFileEntry))
         .then(
+            /** @this {importer.DefaultMediaScanner} */
             function() {
               if (end < entries.length) {
-                return scanChunk(end);
+                return scanBatch(end);
               }
             });
   };
 
-  return scanChunk(0);
+  return scanBatch(0);
 };
 
 /**
@@ -321,9 +342,16 @@
 importer.ScanResult.prototype.isFinal;
 
 /**
- * @return {boolean} true if scanning is invalidated.
+ * Notifies the scan to stop working. Some in progress work
+ * may continue, but no new work will be undertaken.
  */
-importer.ScanResult.prototype.isInvalidated;
+importer.ScanResult.prototype.cancel;
+
+/**
+ * @return {boolean} True if the scan has been canceled. Some
+ * work started prior to cancelation may still be ongoing.
+ */
+importer.ScanResult.prototype.canceled;
 
 /**
  * Returns all files entries discovered so far. The list will be
@@ -335,7 +363,8 @@
 importer.ScanResult.prototype.getFileEntries;
 
 /**
- * Returns a promise that fires when scanning is complete.
+ * Returns a promise that fires when scanning is finished
+ * normally or has been canceled.
  *
  * @return {!Promise<!importer.ScanResult>}
  */
@@ -363,6 +392,9 @@
  * <p>The scan is complete, and the object will become static once the
  * {@code whenFinal} promise resolves.
  *
+ * Note that classes implementing this should provide a read-only
+ * {@code name} field.
+ *
  * @constructor
  * @struct
  * @implements {importer.ScanResult}
@@ -371,6 +403,8 @@
  *     generator used to dedupe within the scan results itself.
  */
 importer.DefaultScanResult = function(hashGenerator) {
+  /** @private {number} */
+  this.scanId_ = importer.generateId();
 
   /** @private {function(!FileEntry): !Promise.<string>} */
   this.createHashcode_ = hashGenerator;
@@ -410,7 +444,7 @@
   /**
    * @private {boolean}
    */
-  this.invalidated_ = false;
+  this.canceled_ = false;
 
   /** @private {!importer.Resolver.<!importer.ScanResult>} */
   this.resolver_ = new importer.Resolver();
@@ -418,6 +452,8 @@
 
 /** @struct */
 importer.DefaultScanResult.prototype = {
+  /** @return {string} */
+  get name() { return 'ScanResult(' + this.scanId_ + ')' },
 
   /** @return {function()} */
   get resolve() { return this.resolver_.resolve.bind(null, this); },
@@ -431,10 +467,6 @@
   return this.resolver_.settled;
 };
 
-importer.DefaultScanResult.prototype.isInvalidated = function() {
-  return this.invalidated_;
-};
-
 /** @override */
 importer.DefaultScanResult.prototype.getFileEntries = function() {
   return this.fileEntries_;
@@ -445,11 +477,14 @@
   return this.resolver_.promise;
 };
 
-/**
- * Invalidates this scan.
- */
-importer.DefaultScanResult.prototype.invalidateScan = function() {
-  this.invalidated_ = true;
+/** @override */
+importer.DefaultScanResult.prototype.cancel = function() {
+  this.canceled_ = true;
+};
+
+/** @override */
+importer.DefaultScanResult.prototype.canceled = function() {
+  return this.canceled_;
 };
 
 /**
diff --git a/ui/file_manager/file_manager/background/js/mock_media_scanner.js b/ui/file_manager/file_manager/background/js/mock_media_scanner.js
index ddc26da..f2335ad 100644
--- a/ui/file_manager/file_manager/background/js/mock_media_scanner.js
+++ b/ui/file_manager/file_manager/background/js/mock_media_scanner.js
@@ -103,6 +103,15 @@
 };
 
 /**
+ * Asserts that the last scan was canceled. Fails if no
+ *     scan exists.
+ */
+TestMediaScanner.prototype.assertLastScanCanceled = function() {
+  assertTrue(this.scans_.length > 0);
+  assertTrue(this.scans_[this.scans_.length - 1].canceled());
+};
+
+/**
  * importer.MediaScanner and importer.ScanResult test double.
  *
  * @constructor
@@ -112,6 +121,9 @@
  * @param {!Array.<!FileEntry>} fileEntries
  */
 function TestScanResult(fileEntries) {
+  /** @private {number} */
+  this.scanId_ = ++TestScanResult.lastId_;
+
   /**
    * List of file entries found while scanning.
    * @type {!Array.<!FileEntry>}
@@ -133,6 +145,9 @@
   /** @type {boolean} */
   this.settled_ = false;
 
+  /** @private {boolean} */
+  this.canceled_ = false;
+
   /** @type {!Promise.<!importer.ScanResult>} */
   this.whenFinal_ = new Promise(
       function(resolve, reject) {
@@ -147,6 +162,15 @@
       }.bind(this));
 }
 
+/** @private {number} */
+TestScanResult.lastId_ = 0;
+
+/** @struct */
+TestScanResult.prototype = {
+  /** @return {string} */
+  get name() { return 'TestScanResult(' + this.scanId_ + ')' }
+};
+
 /** @override */
 TestScanResult.prototype.getFileEntries = function() {
   return this.fileEntries;
@@ -168,8 +192,13 @@
 };
 
 /** @override */
-TestScanResult.prototype.isInvalidated = function() {
-  return false;
+TestScanResult.prototype.cancel = function() {
+  this.canceled_ = true;
+};
+
+/** @override */
+TestScanResult.prototype.canceled = function() {
+  return this.canceled_;
 };
 
 /** @override */
diff --git a/ui/file_manager/file_manager/common/js/importer_common.js b/ui/file_manager/file_manager/common/js/importer_common.js
index 8a441f8..2ec8735 100644
--- a/ui/file_manager/file_manager/common/js/importer_common.js
+++ b/ui/file_manager/file_manager/common/js/importer_common.js
@@ -348,7 +348,7 @@
             if (id) {
               return id;
             }
-            var id = importer.generateMachineId_();
+            var id = importer.generateId();
             return storage.set(importer.Setting.MACHINE_ID, id)
                 .then(
                     function() {
@@ -381,11 +381,9 @@
 };
 
 /**
- * @return {number} A relatively unique six digit integer that is most likely
- *     unique to this machine among a user's machines. Used only to segregate
- *     log files on sync storage.
+ * @return {number} A relatively random six digit integer.
  */
-importer.generateMachineId_ = function() {
+importer.generateId = function() {
   return Math.floor(Math.random() * 899999) + 100000;
 };
 
diff --git a/ui/file_manager/file_manager/foreground/js/import_controller.js b/ui/file_manager/file_manager/foreground/js/import_controller.js
index 058e2dd..93f18df 100644
--- a/ui/file_manager/file_manager/foreground/js/import_controller.js
+++ b/ui/file_manager/file_manager/foreground/js/import_controller.js
@@ -80,6 +80,9 @@
         this.scanner_.removeObserver(listener);
       }.bind(this));
 
+  this.environment_.addWindowCloseListener(
+      this.onWindowClosing_.bind(this));
+
   this.environment_.addVolumeUnmountListener(
       this.onVolumeUnmounted_.bind(this));
 
@@ -128,7 +131,7 @@
 
   switch (event) {
     case importer.ScanEvent.INVALIDATED:
-      this.scanManager_.reset();
+      this.onScanInvalidated_();
     case importer.ScanEvent.FINALIZED:
     case importer.ScanEvent.UPDATED:
       this.checkState_(scan);
@@ -136,6 +139,11 @@
   }
 };
 
+/** @private */
+importer.ImportController.prototype.onWindowClosing_ = function() {
+  this.scanManager_.reset();  // Will cancel any active scans.
+};
+
 /**
  * @param {string} volumeId
  * @private
@@ -160,10 +168,11 @@
       importer.isMediaDirectory(event.newDirEntry, this.environment_)) {
     this.commandWidget_.setDetailsVisible(true);
   }
-  this.scanManager_.clearSelectionScan();
+
+  this.scanManager_.reset();
   if (this.isCurrentDirectoryScannable_()) {
     this.checkState_(
-        this.scanManager_.getCurrentDirectoryScan());
+        this.scanManager_.getDirectoryScan());
   } else {
     this.checkState_();
   }
@@ -171,6 +180,15 @@
 
 /** @private */
 importer.ImportController.prototype.onSelectionChanged_ = function() {
+  // NOTE: Empty selection changed events can and will fire for a directory
+  // before we receive the the corresponding directory changed event
+  // and when the selection is empty. These are spurios events
+  // and we ignore them.
+  if (!this.scanManager_.hasSelectionScan() &&
+      this.environment_.getSelection().length === 0) {
+    return;
+  }
+
   // NOTE: We clear the scan here, but don't immediately initiate
   // a new scan. checkState will attempt to initialize the scan
   // during normal processing.
@@ -181,7 +199,7 @@
   if (this.environment_.getSelection().length === 0 &&
       this.isCurrentDirectoryScannable_()) {
     this.checkState_(
-        this.scanManager_.getCurrentDirectoryScan());
+        this.scanManager_.getDirectoryScan());
   } else {
     this.checkState_();
   }
@@ -199,6 +217,18 @@
   this.checkState_();
 };
 
+/** @private */
+importer.ImportController.prototype.onScanInvalidated_ = function() {
+  this.scanManager_.reset();
+  if (this.environment_.getSelection().length === 0 &&
+      this.isCurrentDirectoryScannable_()) {
+    this.checkState_(
+        this.scanManager_.getDirectoryScan());
+  } else {
+    this.checkState_();
+  }
+};
+
 /**
  * Does book keeping necessary to finalize the active task.
  * @private
@@ -282,9 +312,11 @@
  * @private
  */
 importer.ImportController.prototype.cancel_ = function() {
-  this.activeImport_.task.requestCancel();
-  this.finalizeActiveImport_();
-  this.tracker_.send(metrics.ImportEvents.IMPORT_CANCELLED);
+  if (this.activeImport_) {
+    this.activeImport_.task.requestCancel();
+    this.finalizeActiveImport_();
+    this.tracker_.send(metrics.ImportEvents.IMPORT_CANCELLED);
+  }
 
   this.scanManager_.reset();
   this.checkState_();
@@ -411,14 +443,13 @@
  */
 importer.ImportController.prototype.tryScan_ = function() {
   var entries = this.environment_.getSelection();
-
   if (entries.length) {
     if (entries.every(
         importer.isEligibleEntry.bind(null, this.environment_))) {
       return this.scanManager_.getSelectionScan(entries);
     }
   } else if (this.isCurrentDirectoryScannable_()) {
-    return this.scanManager_.getCurrentDirectoryScan();
+    return this.scanManager_.getDirectoryScan();
   }
 
   return null;
@@ -802,40 +833,53 @@
   this.scanner_ = scanner;
 
   /**
-   * A cache of selection scans by directory (url).
+   * The active files scan, if any.
    *
    * @private {importer.ScanResult}
    */
   this.selectionScan_ = null;
 
   /**
-   * A cache of scans by directory (url).
+   * The active directory scan, if any.
    *
-   * @private {!Object.<string, !importer.ScanResult>}
+   * @private {importer.ScanResult}
    */
-  this.directoryScans_ = {};
+  this.directoryScan_ = null;
 };
 
 /**
- * Forgets all scans.
+ * Cancels and forgets all scans.
  */
 importer.ScanManager.prototype.reset = function() {
   this.clearSelectionScan();
-  this.clearDirectoryScans();
+  this.clearDirectoryScan();
 };
 
 /**
- * Forgets the selection scans for the current directory.
+ * @return {boolean} True if we have an existing selection scan.
+ */
+importer.ScanManager.prototype.hasSelectionScan = function() {
+  return !!this.selectionScan_;
+};
+
+/**
+ * Cancels and forgets the current selection scan, if any.
  */
 importer.ScanManager.prototype.clearSelectionScan = function() {
+  if (this.selectionScan_) {
+    this.selectionScan_.cancel();
+  }
   this.selectionScan_ = null;
 };
 
 /**
- * Forgets directory scans.
+ * Cancels and forgets the current directory scan, if any.
  */
-importer.ScanManager.prototype.clearDirectoryScans = function() {
-  this.directoryScans_ = {};
+importer.ScanManager.prototype.clearDirectoryScan = function() {
+  if (this.directoryScan_) {
+    this.directoryScan_.cancel();
+  }
+  this.directoryScan_ = null;
 };
 
 /**
@@ -843,14 +887,7 @@
  * if none.
  */
 importer.ScanManager.prototype.getActiveScan = function() {
-  if (!!this.selectionScan_) {
-    return this.selectionScan_;
-  }
-  var directory = this.environment_.getCurrentDirectory();
-  if (directory) {
-    return this.directoryScans_[directory.toURL()];
-  }
-  return null;
+  return this.selectionScan_ || this.directoryScan_;
 };
 
 /**
@@ -859,13 +896,7 @@
  *     selection scan or the scan for the current directory.
  */
 importer.ScanManager.prototype.isActiveScan = function(scan) {
-  if (scan === this.selectionScan_) {
-    return true;
-  }
-
-  var directory = this.environment_.getCurrentDirectory();
-  return !!directory &&
-      scan === this.directoryScans_[directory.toURL()];
+  return scan === this.selectionScan_ || scan === this.directoryScan_;
 };
 
 /**
@@ -888,21 +919,15 @@
  *
  * @return {importer.ScanResult}
  */
-importer.ScanManager.prototype.getCurrentDirectoryScan = function() {
-  var directory = this.environment_.getCurrentDirectory();
-  if (!directory) {
-    return null;
+importer.ScanManager.prototype.getDirectoryScan = function() {
+  if (!this.directoryScan_) {
+    var directory = this.environment_.getCurrentDirectory();
+    if (directory) {
+      this.directoryScan_ = this.scanner_.scanDirectory(
+          /** @type {!DirectoryEntry} */ (directory));
+    }
   }
-
-  var url = directory.toURL();
-  var scan = this.directoryScans_[url];
-  if (!scan) {
-    scan = this.scanner_.scanDirectory(
-        /** @type {!DirectoryEntry} */ (directory));
-    this.directoryScans_[url] = scan;
-  }
-
-  return scan;
+  return this.directoryScan_;
 };
 
 /**
@@ -945,6 +970,14 @@
 importer.ControllerEnvironment.prototype.getFreeStorageSpace;
 
 /**
+ * Installs a 'window closed' listener. Listener is called just
+ * just before the window is closed. Any business must be
+ * done synchronously.
+ * @param {function()} listener
+ */
+importer.ControllerEnvironment.prototype.addWindowCloseListener;
+
+/**
  * Installs an 'unmount' listener. Listener is called with
  * the corresponding volume id when a volume is unmounted.
  * @param {function(string)} listener
@@ -1049,22 +1082,6 @@
 };
 
 /** @override */
-importer.RuntimeControllerEnvironment.prototype.addVolumeUnmountListener =
-    function(listener) {
-  // TODO(smckay): remove listeners when the page is torn down.
-  chrome.fileManagerPrivate.onMountCompleted.addListener(
-      /**
-       * @param {!MountCompletedEvent} event
-       * @this {importer.RuntimeControllerEnvironment}
-       */
-      function(event) {
-        if (event.eventType === 'unmount') {
-          listener(event.volumeMetadata.volumeId);
-        }
-      });
-};
-
-/** @override */
 importer.RuntimeControllerEnvironment.prototype.getFreeStorageSpace =
     function() {
   // Checking DOWNLOADS returns the amount of available local storage space.
@@ -1089,6 +1106,28 @@
 };
 
 /** @override */
+importer.RuntimeControllerEnvironment.prototype.addWindowCloseListener =
+    function(listener) {
+  window.addEventListener('pagehide', listener);
+};
+
+/** @override */
+importer.RuntimeControllerEnvironment.prototype.addVolumeUnmountListener =
+    function(listener) {
+  // TODO(smckay): remove listeners when the page is torn down.
+  chrome.fileManagerPrivate.onMountCompleted.addListener(
+      /**
+       * @param {!MountCompletedEvent} event
+       * @this {importer.RuntimeControllerEnvironment}
+       */
+      function(event) {
+        if (event.eventType === 'unmount') {
+          listener(event.volumeMetadata.volumeId);
+        }
+      });
+};
+
+/** @override */
 importer.RuntimeControllerEnvironment.prototype.addDirectoryChangedListener =
     function(listener) {
   // TODO(smckay): remove listeners when the page is torn down.
diff --git a/ui/file_manager/file_manager/foreground/js/import_controller_unittest.html b/ui/file_manager/file_manager/foreground/js/import_controller_unittest.html
index cd21039..329b290 100644
--- a/ui/file_manager/file_manager/foreground/js/import_controller_unittest.html
+++ b/ui/file_manager/file_manager/foreground/js/import_controller_unittest.html
@@ -22,6 +22,7 @@
   <script src="../../common/js/metrics_events.js"></script>
   <script src="../../common/js/test_tracker.js"></script>
   <script src="../../common/js/volume_manager_common.js"></script>
+  <script src="../../common/js/file_type.js"></script>
   <script src="../../common/js/importer_common.js"></script>
 
   <script src="../../background/js/mock_file_operation_manager.js"></script>
diff --git a/ui/file_manager/file_manager/foreground/js/import_controller_unittest.js b/ui/file_manager/file_manager/foreground/js/import_controller_unittest.js
index 3cd55df..c280757 100644
--- a/ui/file_manager/file_manager/foreground/js/import_controller_unittest.js
+++ b/ui/file_manager/file_manager/foreground/js/import_controller_unittest.js
@@ -26,6 +26,9 @@
 /** @type {!importer.TestCommandWidget} */
 var widget;
 
+/** @type {!DirectoryEntry} */
+var nonDcimDirectory;
+
 /**
  * @enum {string}
  */
@@ -47,6 +50,10 @@
 
   widget = new importer.TestCommandWidget();
 
+  nonDcimDirectory = new MockDirectoryEntry(
+      new MockFileSystem('testFs'),
+      '/jellybeans/');
+
   volumeManager = new MockVolumeManager();
   MockVolumeManager.installMockSingleton(volumeManager);
 
@@ -95,24 +102,25 @@
       ],
       '/DCIM');
 
-  environment.directoryChangedListener_(EMPTY_EVENT);
+  var dcim = environment.getCurrentDirectory();
+
+  environment.directoryChangedListener(EMPTY_EVENT);
   var promise = widget.updateResolver.promise.then(
+      function() {
+        // Reset the promise so we can wait on a second widget update.
+        widget.resetPromises();
+        environment.setCurrentDirectory(nonDcimDirectory);
+        environment.simulateUnmount();
+
+        environment.setCurrentDirectory(dcim);
+        environment.directoryChangedListener(EMPTY_EVENT);
+        // Return the new promise, so subsequent "thens" only
+        // fire once the widget has been updated again.
+        return widget.updateResolver.promise;
+      }).then(
           function() {
-            // Reset the promise so we can wait on a second widget update.
-            widget.resetPromises();
-
-            // Faux unmount the volume, then request an update again.
-            // A fresh new scan should be started.
-            environment.simulateUnmount();
-
-            environment.directoryChangedListener_(EMPTY_EVENT);
-            // Return the new promise, so subsequent "thens" only
-            // fire once the widget has been updated again.
-            return widget.updateResolver.promise;
-          }).then(
-            function() {
-              mediaScanner.assertScanCount(2);
-            });
+            mediaScanner.assertScanCount(2);
+          });
 
   reportPromise(promise, callback);
 }
@@ -128,10 +136,71 @@
       ],
       '/DCIM');
 
-  environment.directoryChangedListener_(EMPTY_EVENT);
+  environment.directoryChangedListener(EMPTY_EVENT);
   reportPromise(widget.updateResolver.promise, callback);
 }
 
+function testDirectoryChange_CancelsScan(callback) {
+  var controller = createController(
+      VolumeManagerCommon.VolumeType.MTP,
+      'mtp-volume',
+      [
+        '/DCIM/',
+        '/DCIM/photos0/',
+        '/DCIM/photos0/IMG00001.jpg',
+        '/DCIM/photos0/IMG00002.jpg',
+        '/DCIM/photos1/',
+        '/DCIM/photos1/IMG00001.jpg',
+        '/DCIM/photos1/IMG00003.jpg'
+      ],
+      '/DCIM');
+
+  environment.directoryChangedListener(EMPTY_EVENT);
+  var promise = widget.updateResolver.promise.then(
+      function() {
+        // Reset the promise so we can wait on a second widget update.
+        widget.resetPromises();
+        environment.setCurrentDirectory(nonDcimDirectory);
+        environment.directoryChangedListener(EMPTY_EVENT);
+      }).then(
+          function() {
+            mediaScanner.assertScanCount(1);
+            mediaScanner.assertLastScanCanceled();
+          });
+
+  reportPromise(promise, callback);
+}
+
+function testWindowClose_CancelsScan(callback) {
+  var controller = createController(
+      VolumeManagerCommon.VolumeType.MTP,
+      'mtp-volume',
+      [
+        '/DCIM/',
+        '/DCIM/photos0/',
+        '/DCIM/photos0/IMG00001.jpg',
+        '/DCIM/photos0/IMG00002.jpg',
+        '/DCIM/photos1/',
+        '/DCIM/photos1/IMG00001.jpg',
+        '/DCIM/photos1/IMG00003.jpg'
+      ],
+      '/DCIM');
+
+  environment.directoryChangedListener(EMPTY_EVENT);
+  var promise = widget.updateResolver.promise.then(
+      function() {
+        // Reset the promise so we can wait on a second widget update.
+        widget.resetPromises();
+        environment.windowCloseListener();
+      }).then(
+          function() {
+            mediaScanner.assertScanCount(1);
+            mediaScanner.assertLastScanCanceled();
+          });
+
+  reportPromise(promise, callback);
+}
+
 function testDirectoryChange_DetailsPanelVisibility_InitialChangeDir() {
   var controller = createController(
       VolumeManagerCommon.VolumeType.MTP,
@@ -148,7 +217,7 @@
       new MockFileSystem('testFs'),
       '/DCIM/');
 
-  environment.directoryChangedListener_(event);
+  environment.directoryChangedListener(event);
   assertTrue(widget.detailsVisible);
 }
 
@@ -170,7 +239,7 @@
   // Any previous dir at all will skip the new window logic.
   event.previousDirEntry = event.newDirEntry;
 
-  environment.directoryChangedListener_(event);
+  environment.directoryChangedListener(event);
   assertFalse(widget.detailsVisible);
 }
 
@@ -185,7 +254,14 @@
       ],
       '/DCIM');
 
-  environment.selectionChangedListener_();
+  var fileSystem = new MockFileSystem('testFs');
+  // ensure there is some content in the scan so the code that depends
+  // on this state doesn't croak which it finds it missing.
+  environment.selection.push(
+      new MockFileEntry(fileSystem, '/DCIM/photos0/IMG00001.jpg', {size: 0}));
+
+  environment.selectionChangedListener();
+  mediaScanner.finalizeScans();
   reportPromise(widget.updateResolver.promise, callback);
 }
 
@@ -206,7 +282,7 @@
   mediaScanner.fileEntries.push(
       new MockFileEntry(fileSystem, '/DCIM/photos0/IMG00001.jpg', {size: 0}));
 
-  environment.directoryChangedListener_(EMPTY_EVENT);  // initiates a scan.
+  environment.directoryChangedListener(EMPTY_EVENT);  // initiates a scan.
   widget.resetPromises();
   mediaScanner.finalizeScans();
 
@@ -263,19 +339,19 @@
       new MockFileEntry(fileSystem, '/DCIM/photos0/IMG00001.jpg', {size: 0}));
 
   // First we need to force the controller into a scanning state.
-  environment.directoryChangedListener_(EMPTY_EVENT);
+  environment.directoryChangedListener(EMPTY_EVENT);
 
   return widget.updateResolver.promise.then(
-          function() {
-            widget.resetPromises();
-            mediaScanner.finalizeScans();
-            return widget.updateResolver.promise.then(
-                function() {
-                  widget.resetPromises();
-                  widget.click(clickSource);
-                  return mediaImporter.importResolver.promise;
-                });
-          });
+      function() {
+        widget.resetPromises();
+        mediaScanner.finalizeScans();
+        return widget.updateResolver.promise.then(
+            function() {
+              widget.resetPromises();
+              widget.click(clickSource);
+              return mediaImporter.importResolver.promise;
+            });
+      });
 }
 
 /**
@@ -394,14 +470,17 @@
   /** @private {!DirectoryEntry} */
   this.directory_ = directory;
 
-  /** @private {function(string)} */
-  this.volumeUnmountListener_;
+  /** @public {function()} */
+  this.windowCloseListener;
 
-  /** @private {function()} */
-  this.directoryChangedListener_;
+  /** @public {function(string)} */
+  this.volumeUnmountListener;
 
-  /** @private {function()} */
-  this.selectionChangedListener_;
+  /** @public {function()} */
+  this.directoryChangedListener;
+
+  /** @public {function()} */
+  this.selectionChangedListener;
 
   /** @public {!Entry} */
   this.selection = [];
@@ -455,21 +534,27 @@
 };
 
 /** @override */
+TestControllerEnvironment.prototype.addWindowCloseListener =
+    function(listener) {
+  this.windowCloseListener = listener;
+};
+
+/** @override */
 TestControllerEnvironment.prototype.addVolumeUnmountListener =
     function(listener) {
-  this.volumeUnmountListener_ = listener;
+  this.volumeUnmountListener = listener;
 };
 
 /** @override */
 TestControllerEnvironment.prototype.addDirectoryChangedListener =
     function(listener) {
-  this.directoryChangedListener_ = listener;
+  this.directoryChangedListener = listener;
 };
 
 /** @override */
 TestControllerEnvironment.prototype.addSelectionChangedListener =
     function(listener) {
-  this.selectionChangedListener_ = listener;
+  this.selectionChangedListener = listener;
 };
 
 /** @override */
@@ -493,7 +578,7 @@
  * Simulates an unmount event.
  */
 TestControllerEnvironment.prototype.simulateUnmount = function() {
-  this.volumeUnmountListener_(this.volumeInfo_.volumeId);
+  this.volumeUnmountListener(this.volumeInfo_.volumeId);
 };
 
 /**