Fix PollingFileWatcher.ready for files that don't exist (#157)

There were a few issues here:

- FileWatcher.ready never fired for files that don't exist because of logic inside FileWatcher existing early if the modification time was `null`
- The test I recently added trying to catch this was incorrectly passing because the mock timestamp code was set so that files that had not been created would return a 0-mtime whereas in the real implementation they return `null`

So this change

a) updates the mock to return `null` for uncreated files (to match the real implementation) which breaks a bunch of tests

b) fixes those tests by updating FileWatcher._poll() to handle `null` mtime separately from being the first poll.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7743fc7..7893daf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## 1.1.1-wip
 
+- Ensure `PollingFileWatcher.ready` completes for files that do not exist.
+
 ## 1.1.0
 
 - Require Dart SDK >= 3.0.0
diff --git a/lib/src/file_watcher/polling.dart b/lib/src/file_watcher/polling.dart
index 6f1eee4..15ff9ab 100644
--- a/lib/src/file_watcher/polling.dart
+++ b/lib/src/file_watcher/polling.dart
@@ -39,8 +39,7 @@
 
   /// The previous modification time of the file.
   ///
-  /// Used to tell when the file was modified. This is `null` before the file's
-  /// mtime has first been checked.
+  /// `null` indicates the file does not (or did not on the last poll) exist.
   DateTime? _lastModified;
 
   _PollingFileWatcher(this.path, Duration pollingDelay) {
@@ -50,13 +49,14 @@
 
   /// Checks the mtime of the file and whether it's been removed.
   Future<void> _poll() async {
-    // We don't mark the file as removed if this is the first poll (indicated by
-    // [_lastModified] being null). Instead, below we forward the dart:io error
-    // that comes from trying to read the mtime below.
+    // We don't mark the file as removed if this is the first poll. Instead,
+    // below we forward the dart:io error that comes from trying to read the
+    // mtime below.
     var pathExists = await File(path).exists();
     if (_eventsController.isClosed) return;
 
     if (_lastModified != null && !pathExists) {
+      _flagReady();
       _eventsController.add(WatchEvent(ChangeType.REMOVE, path));
       unawaited(close());
       return;
@@ -67,22 +67,34 @@
       modified = await modificationTime(path);
     } on FileSystemException catch (error, stackTrace) {
       if (!_eventsController.isClosed) {
+        _flagReady();
         _eventsController.addError(error, stackTrace);
         await close();
       }
     }
-    if (_eventsController.isClosed) return;
+    if (_eventsController.isClosed) {
+      _flagReady();
+      return;
+    }
 
-    if (_lastModified == modified) return;
-
-    if (_lastModified == null) {
+    if (!isReady) {
       // If this is the first poll, don't emit an event, just set the last mtime
       // and complete the completer.
       _lastModified = modified;
+      _flagReady();
+      return;
+    }
+
+    if (_lastModified == modified) return;
+
+    _lastModified = modified;
+    _eventsController.add(WatchEvent(ChangeType.MODIFY, path));
+  }
+
+  /// Flags this watcher as ready if it has not already been done.
+  void _flagReady() {
+    if (!isReady) {
       _readyCompleter.complete();
-    } else {
-      _lastModified = modified;
-      _eventsController.add(WatchEvent(ChangeType.MODIFY, path));
     }
   }
 
diff --git a/lib/src/stat.dart b/lib/src/stat.dart
index 06e3feb..fe0f155 100644
--- a/lib/src/stat.dart
+++ b/lib/src/stat.dart
@@ -6,7 +6,7 @@
 
 /// A function that takes a file path and returns the last modified time for
 /// the file at that path.
-typedef MockTimeCallback = DateTime Function(String path);
+typedef MockTimeCallback = DateTime? Function(String path);
 
 MockTimeCallback? _mockTimeCallback;
 
diff --git a/test/utils.dart b/test/utils.dart
index 214d669..7867b9f 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -67,7 +67,7 @@
         'Path is not in the sandbox: $path not in ${d.sandbox}');
 
     var mtime = _mockFileModificationTimes[normalized];
-    return DateTime.fromMillisecondsSinceEpoch(mtime ?? 0);
+    return mtime != null ? DateTime.fromMillisecondsSinceEpoch(mtime) : null;
   });
 
   // We want to wait until we're ready *after* we subscribe to the watcher's
@@ -195,6 +195,11 @@
 Future allowModifyEvent(String path) =>
     _expectOrCollect(mayEmit(isWatchEvent(ChangeType.MODIFY, path)));
 
+/// Track a fake timestamp to be used when writing files. This always increases
+/// so that files that are deleted and re-created do not have their timestamp
+/// set back to a previously used value.
+int _nextTimestamp = 1;
+
 /// Schedules writing a file in the sandbox at [path] with [contents].
 ///
 /// If [contents] is omitted, creates an empty file. If [updateModified] is
@@ -216,14 +221,15 @@
   if (updateModified) {
     path = p.normalize(path);
 
-    _mockFileModificationTimes.update(path, (value) => value + 1,
-        ifAbsent: () => 1);
+    _mockFileModificationTimes[path] = _nextTimestamp++;
   }
 }
 
 /// Schedules deleting a file in the sandbox at [path].
 void deleteFile(String path) {
   File(p.join(d.sandbox, path)).deleteSync();
+
+  _mockFileModificationTimes.remove(path);
 }
 
 /// Schedules renaming a file in the sandbox from [from] to [to].
@@ -245,6 +251,16 @@
 /// Schedules renaming a directory in the sandbox from [from] to [to].
 void renameDir(String from, String to) {
   Directory(p.join(d.sandbox, from)).renameSync(p.join(d.sandbox, to));
+
+  // Migrate timestamps for any files in this folder.
+  final knownFilePaths = _mockFileModificationTimes.keys.toList();
+  for (final filePath in knownFilePaths) {
+    if (p.isWithin(from, filePath)) {
+      _mockFileModificationTimes[filePath.replaceAll(from, to)] =
+          _mockFileModificationTimes[filePath]!;
+      _mockFileModificationTimes.remove(filePath);
+    }
+  }
 }
 
 /// Schedules deleting a directory in the sandbox at [path].