From b22f6e41dbbdf8c1a1e5a344201f327b565e5862 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 5 Jun 2024 18:26:39 -0700 Subject: [PATCH 01/10] Simplify flushPeriodicTimers logic If we reach this line, `_timers.isNotEmpty` must be true. That's because this callback's only call site is the line `if(!predicate(timer)) break;` in `_fireTimersWhile`, and there's a check a few lines above there that would have broken out of the loop if `_timers` were empty. --- lib/fake_async.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index 415cb96..a68e75b 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -217,7 +217,7 @@ class FakeAsync { throw StateError('Exceeded timeout $timeout while flushing timers'); } - if (flushPeriodicTimers) return _timers.isNotEmpty; + if (flushPeriodicTimers) return true; // Continue firing timers until the only ones left are periodic *and* // every periodic timer has had a change to run against the final From fb5f59c7adb09b9119b6f5aa23020ecaee231feb Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 5 Jun 2024 18:29:28 -0700 Subject: [PATCH 02/10] Rearrange logic around flushPeriodicTimers This version is exactly equivalent via Boolean algebra, and will make for a bit simpler of a diff in the next refactor. --- lib/fake_async.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index a68e75b..df78370 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -217,13 +217,15 @@ class FakeAsync { throw StateError('Exceeded timeout $timeout while flushing timers'); } - if (flushPeriodicTimers) return true; + // With [flushPeriodicTimers] false, continue firing timers only until + // all remaining timers are periodic *and* every periodic timer has had + // a chance to run against the final value of [_elapsed]. + if (!flushPeriodicTimers) { + return !_timers + .every((timer) => timer.isPeriodic && timer._nextCall > _elapsed); + } - // Continue firing timers until the only ones left are periodic *and* - // every periodic timer has had a change to run against the final - // value of [_elapsed]. - return _timers - .any((timer) => !timer.isPeriodic || timer._nextCall <= _elapsed); + return true; }); } From a78ba9160c21fa1f6202f78b59976fd47695b05a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 5 Jun 2024 18:26:39 -0700 Subject: [PATCH 03/10] Add FakeAsync.runNextTimer Fixes #84. --- CHANGELOG.md | 4 +- lib/fake_async.dart | 57 +++++++++++++---------- pubspec.yaml | 2 +- test/fake_async_test.dart | 95 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c8b85..df28911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -## 1.3.2-wip +## 1.4.0-wip * Require Dart 3.3 +* Add `FakeAsync.runNextTimer`, a single-step analogue + of `FakeAsync.flushTimers`. ## 1.3.1 diff --git a/lib/fake_async.dart b/lib/fake_async.dart index df78370..75deba4 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -138,7 +138,7 @@ class FakeAsync { } _elapsingTo = _elapsed + duration; - _fireTimersWhile((next) => next._nextCall <= _elapsingTo!); + while (runNextTimer(timeout: _elapsingTo! - _elapsed)) {} _elapseTo(_elapsingTo!); _elapsingTo = null; } @@ -211,41 +211,50 @@ class FakeAsync { {Duration timeout = const Duration(hours: 1), bool flushPeriodicTimers = true}) { final absoluteTimeout = _elapsed + timeout; - _fireTimersWhile((timer) { - if (timer._nextCall > absoluteTimeout) { - // TODO(nweiz): Make this a [TimeoutException]. - throw StateError('Exceeded timeout $timeout while flushing timers'); - } - + for (;;) { // With [flushPeriodicTimers] false, continue firing timers only until // all remaining timers are periodic *and* every periodic timer has had // a chance to run against the final value of [_elapsed]. if (!flushPeriodicTimers) { - return !_timers - .every((timer) => timer.isPeriodic && timer._nextCall > _elapsed); + if (_timers + .every((timer) => timer.isPeriodic && timer._nextCall > _elapsed)) { + break; + } } - return true; - }); + if (!runNextTimer(timeout: absoluteTimeout - _elapsed)) { + if (_timers.isEmpty) break; + + // TODO(nweiz): Make this a [TimeoutException]. + throw StateError('Exceeded timeout $timeout while flushing timers'); + } + } } - /// Invoke the callback for each timer until [predicate] returns `false` for - /// the next timer that would be fired. + /// Elapses time to run one timer, if any timer exists. /// - /// Microtasks are flushed before and after each timer is fired. Before each - /// timer fires, [_elapsed] is updated to the appropriate duration. - void _fireTimersWhile(bool Function(FakeTimer timer) predicate) { - flushMicrotasks(); - for (;;) { - if (_timers.isEmpty) break; + /// Microtasks are flushed before and after the timer runs. Before the + /// timer runs, [elapsed] is updated to the appropriate value. + /// + /// The [timeout] controls how much fake time may elapse. If non-null, + /// then timers further in the future than the given duration will be ignored. + /// + /// Returns true if a timer was run, false otherwise. + bool runNextTimer({Duration? timeout}) { + final absoluteTimeout = timeout == null ? null : _elapsed + timeout; - final timer = minBy(_timers, (FakeTimer timer) => timer._nextCall)!; - if (!predicate(timer)) break; + flushMicrotasks(); - _elapseTo(timer._nextCall); - timer._fire(); - flushMicrotasks(); + if (_timers.isEmpty) return false; + final timer = minBy(_timers, (FakeTimer timer) => timer._nextCall)!; + if (absoluteTimeout != null && timer._nextCall > absoluteTimeout) { + return false; } + + _elapseTo(timer._nextCall); + timer._fire(); + flushMicrotasks(); + return true; } /// Creates a new timer controlled by `this` that fires [callback] after diff --git a/pubspec.yaml b/pubspec.yaml index 191139f..33f87df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: fake_async -version: 1.3.2-wip +version: 1.4.0-wip description: >- Fake asynchronous events such as timers and microtasks for deterministic testing. diff --git a/test/fake_async_test.dart b/test/fake_async_test.dart index 7aefc5f..8216b1c 100644 --- a/test/fake_async_test.dart +++ b/test/fake_async_test.dart @@ -446,6 +446,101 @@ void main() { }); }); + group('runNextTimer', () { + test('should run the earliest timer', () { + FakeAsync().run((async) { + var last = 0; + Timer(const Duration(days: 2), () => last = 2); + Timer(const Duration(days: 1), () => last = 1); + Timer(const Duration(days: 3), () => last = 3); + expect(async.runNextTimer(), true); + expect(last, 1); + }); + }); + + test('should return false if no timers exist', () { + FakeAsync().run((async) { + expect(async.runNextTimer(), false); + }); + }); + + test('should run microtasks before choosing timer', () { + FakeAsync().run((async) { + var last = 0; + Timer(const Duration(days: 2), () => last = 2); + scheduleMicrotask(() => Timer(const Duration(days: 1), () => last = 1)); + expect(async.runNextTimer(), true); + expect(last, 1); + expect(async.runNextTimer(), true); + expect(last, 2); + }); + }); + + test('should run microtasks before deciding no timers exist', () { + FakeAsync().run((async) { + var last = 0; + scheduleMicrotask(() => Timer(const Duration(days: 1), () => last = 1)); + expect(async.runNextTimer(), true); + expect(last, 1); + }); + }); + + test('should run microtasks after timer', () { + FakeAsync().run((async) { + var ran = false; + Timer.run(() => scheduleMicrotask(() => ran = true)); + expect(async.runNextTimer(), true); + expect(ran, true); + }); + }); + + test('should update elapsed before running timer', () { + FakeAsync().run((async) { + Duration? time; + Timer(const Duration(days: 1), () => time = async.elapsed); + expect(async.runNextTimer(), true); + expect(time, const Duration(days: 1)); + }); + }); + + test('should apply timeout', () { + FakeAsync().run((async) { + var ran = false; + Timer(const Duration(days: 1), () => ran = true); + expect(async.runNextTimer(timeout: const Duration(hours: 1)), false); + expect(ran, false); + }); + }); + + test('should apply timeout as non-strict bound', () { + FakeAsync().run((async) { + var ran = false; + Timer(const Duration(hours: 1), () => ran = true); + expect(async.runNextTimer(timeout: const Duration(hours: 1)), true); + expect(ran, true); + }); + }); + + test('should apply timeout relative to current time', () { + FakeAsync().run((async) { + var ran = false; + Timer(const Duration(hours: 3), () => ran = true); + async.elapse(const Duration(hours: 2)); + expect(async.runNextTimer(timeout: const Duration(hours: 2)), true); + expect(ran, true); + }); + }); + + test('should have no timeout by default', () { + FakeAsync().run((async) { + var ran = false; + Timer(const Duration(microseconds: 1 << 52), () => ran = true); + expect(async.runNextTimer(), true); + expect(ran, true); + }); + }); + }); + group('stats', () { test('should report the number of pending microtasks', () { FakeAsync().run((async) { From 4916d20f107df85b19640bdc9a00fcac86d24826 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 20:58:34 -0700 Subject: [PATCH 04/10] Apply several small comments --- lib/fake_async.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index 75deba4..c024065 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -211,7 +211,7 @@ class FakeAsync { {Duration timeout = const Duration(hours: 1), bool flushPeriodicTimers = true}) { final absoluteTimeout = _elapsed + timeout; - for (;;) { + while (true) { // With [flushPeriodicTimers] false, continue firing timers only until // all remaining timers are periodic *and* every periodic timer has had // a chance to run against the final value of [_elapsed]. @@ -233,13 +233,14 @@ class FakeAsync { /// Elapses time to run one timer, if any timer exists. /// - /// Microtasks are flushed before and after the timer runs. Before the - /// timer runs, [elapsed] is updated to the appropriate value. + /// Microtasks are flushed before identifying the timer to run, + /// and again after the timer runs. + /// Before the timer runs, [elapsed] is updated to the appropriate value. /// /// The [timeout] controls how much fake time may elapse. If non-null, /// then timers further in the future than the given duration will be ignored. /// - /// Returns true if a timer was run, false otherwise. + /// Returns `true` if a timer was run, `false` otherwise. bool runNextTimer({Duration? timeout}) { final absoluteTimeout = timeout == null ? null : _elapsed + timeout; From ae309c9c95bd5f5df79e8de48ac1b8e7873c042a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 21:00:28 -0700 Subject: [PATCH 05/10] Internal helper taking absolute time --- lib/fake_async.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index c024065..d0c8db0 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -138,7 +138,7 @@ class FakeAsync { } _elapsingTo = _elapsed + duration; - while (runNextTimer(timeout: _elapsingTo! - _elapsed)) {} + while (_runNextTimer(_elapsingTo!)) {} _elapseTo(_elapsingTo!); _elapsingTo = null; } @@ -222,7 +222,7 @@ class FakeAsync { } } - if (!runNextTimer(timeout: absoluteTimeout - _elapsed)) { + if (!_runNextTimer(absoluteTimeout)) { if (_timers.isEmpty) break; // TODO(nweiz): Make this a [TimeoutException]. @@ -242,13 +242,15 @@ class FakeAsync { /// /// Returns `true` if a timer was run, `false` otherwise. bool runNextTimer({Duration? timeout}) { - final absoluteTimeout = timeout == null ? null : _elapsed + timeout; + return _runNextTimer(timeout == null ? null : _elapsed + timeout); + } + bool _runNextTimer([Duration? until]) { flushMicrotasks(); if (_timers.isEmpty) return false; final timer = minBy(_timers, (FakeTimer timer) => timer._nextCall)!; - if (absoluteTimeout != null && timer._nextCall > absoluteTimeout) { + if (until != null && timer._nextCall > until) { return false; } From 158b5d801bd11ffbe786ca7b5af2dfafa949b3f5 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 21:07:44 -0700 Subject: [PATCH 06/10] Rename from "timeout" to "within" --- lib/fake_async.dart | 8 ++++---- test/fake_async_test.dart | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index d0c8db0..b8fa485 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -237,12 +237,12 @@ class FakeAsync { /// and again after the timer runs. /// Before the timer runs, [elapsed] is updated to the appropriate value. /// - /// The [timeout] controls how much fake time may elapse. If non-null, - /// then timers further in the future than the given duration will be ignored. + /// When [within] is non-null, only timers that are due within the + /// given duration will be considered. /// /// Returns `true` if a timer was run, `false` otherwise. - bool runNextTimer({Duration? timeout}) { - return _runNextTimer(timeout == null ? null : _elapsed + timeout); + bool runNextTimer({Duration? within}) { + return _runNextTimer(within == null ? null : _elapsed + within); } bool _runNextTimer([Duration? until]) { diff --git a/test/fake_async_test.dart b/test/fake_async_test.dart index 8216b1c..52cde87 100644 --- a/test/fake_async_test.dart +++ b/test/fake_async_test.dart @@ -503,35 +503,35 @@ void main() { }); }); - test('should apply timeout', () { + test('should apply `within`', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(days: 1), () => ran = true); - expect(async.runNextTimer(timeout: const Duration(hours: 1)), false); + expect(async.runNextTimer(within: const Duration(hours: 1)), false); expect(ran, false); }); }); - test('should apply timeout as non-strict bound', () { + test('should apply `within` as non-strict bound', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(hours: 1), () => ran = true); - expect(async.runNextTimer(timeout: const Duration(hours: 1)), true); + expect(async.runNextTimer(within: const Duration(hours: 1)), true); expect(ran, true); }); }); - test('should apply timeout relative to current time', () { + test('should apply `within` relative to current time', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(hours: 3), () => ran = true); async.elapse(const Duration(hours: 2)); - expect(async.runNextTimer(timeout: const Duration(hours: 2)), true); + expect(async.runNextTimer(within: const Duration(hours: 2)), true); expect(ran, true); }); }); - test('should have no timeout by default', () { + test('should have no time bound by default', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(microseconds: 1 << 52), () => ran = true); From 02188dc01057c10bc7db8a206895770d699f815e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 21:34:12 -0700 Subject: [PATCH 07/10] Add more discussion in docs --- lib/fake_async.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index b8fa485..967eab9 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -233,6 +233,11 @@ class FakeAsync { /// Elapses time to run one timer, if any timer exists. /// + /// Running one timer at a time, rather than just advancing time such as + /// with [elapse] or [flushTimers], can be useful if a test wants to run + /// its own logic between each timer event, for example to verify invariants + /// or to end the test early in case of an error. + /// /// Microtasks are flushed before identifying the timer to run, /// and again after the timer runs. /// Before the timer runs, [elapsed] is updated to the appropriate value. @@ -240,6 +245,10 @@ class FakeAsync { /// When [within] is non-null, only timers that are due within the /// given duration will be considered. /// + /// Because multiple timers may be due at the same time, a call to this + /// method may leave the time advanced to where other timers are due. + /// Doing an `elapse(Duration.zero)` afterwards may trigger more timers. + /// /// Returns `true` if a timer was run, `false` otherwise. bool runNextTimer({Duration? within}) { return _runNextTimer(within == null ? null : _elapsed + within); From 4fd436520d35ff487656644abef0d06c06fa911f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 21:43:11 -0700 Subject: [PATCH 08/10] Take an absolute time bound, rather than relative --- lib/fake_async.dart | 14 +++++--------- test/fake_async_test.dart | 16 +++++++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index 967eab9..0a19361 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -138,7 +138,7 @@ class FakeAsync { } _elapsingTo = _elapsed + duration; - while (_runNextTimer(_elapsingTo!)) {} + while (runNextTimer(until: _elapsingTo!)) {} _elapseTo(_elapsingTo!); _elapsingTo = null; } @@ -222,7 +222,7 @@ class FakeAsync { } } - if (!_runNextTimer(absoluteTimeout)) { + if (!runNextTimer(until: absoluteTimeout)) { if (_timers.isEmpty) break; // TODO(nweiz): Make this a [TimeoutException]. @@ -242,19 +242,15 @@ class FakeAsync { /// and again after the timer runs. /// Before the timer runs, [elapsed] is updated to the appropriate value. /// - /// When [within] is non-null, only timers that are due within the - /// given duration will be considered. + /// When [until] is non-null, only timers due up until the given time + /// will be considered, in terms of [elapsed]. /// /// Because multiple timers may be due at the same time, a call to this /// method may leave the time advanced to where other timers are due. /// Doing an `elapse(Duration.zero)` afterwards may trigger more timers. /// /// Returns `true` if a timer was run, `false` otherwise. - bool runNextTimer({Duration? within}) { - return _runNextTimer(within == null ? null : _elapsed + within); - } - - bool _runNextTimer([Duration? until]) { + bool runNextTimer({Duration? until}) { flushMicrotasks(); if (_timers.isEmpty) return false; diff --git a/test/fake_async_test.dart b/test/fake_async_test.dart index 52cde87..2b8f40c 100644 --- a/test/fake_async_test.dart +++ b/test/fake_async_test.dart @@ -503,30 +503,32 @@ void main() { }); }); - test('should apply `within`', () { + test('should apply `until`', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(days: 1), () => ran = true); - expect(async.runNextTimer(within: const Duration(hours: 1)), false); + expect(async.runNextTimer(until: const Duration(hours: 1)), false); expect(ran, false); }); }); - test('should apply `within` as non-strict bound', () { + test('should apply `until` as non-strict bound', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(hours: 1), () => ran = true); - expect(async.runNextTimer(within: const Duration(hours: 1)), true); + expect(async.runNextTimer(until: const Duration(hours: 1)), true); expect(ran, true); }); }); - test('should apply `within` relative to current time', () { + test('should apply `until` relative to start', () { FakeAsync().run((async) { var ran = false; Timer(const Duration(hours: 3), () => ran = true); - async.elapse(const Duration(hours: 2)); - expect(async.runNextTimer(within: const Duration(hours: 2)), true); + async.elapse(const Duration(hours: 1)); + expect(async.runNextTimer(until: const Duration(hours: 2)), false); + expect(ran, false); + expect(async.runNextTimer(until: const Duration(hours: 3)), true); expect(ran, true); }); }); From 6227b63dd0bce0ad03ecf831f4d27f900f5e0337 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 22:01:32 -0700 Subject: [PATCH 09/10] Add test cases on not updating elapsed --- test/fake_async_test.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/fake_async_test.dart b/test/fake_async_test.dart index 2b8f40c..1e4d231 100644 --- a/test/fake_async_test.dart +++ b/test/fake_async_test.dart @@ -500,6 +500,15 @@ void main() { Timer(const Duration(days: 1), () => time = async.elapsed); expect(async.runNextTimer(), true); expect(time, const Duration(days: 1)); + expect(async.elapsed, const Duration(days: 1)); + }); + }); + + test('should not update elapsed if no timers exist', () { + FakeAsync().run((async) { + async.elapse(const Duration(hours: 1)); + expect(async.runNextTimer(), false); + expect(async.elapsed, const Duration(hours: 1)); }); }); @@ -512,6 +521,14 @@ void main() { }); }); + test('should not update elapsed if all timers are past `until`', () { + FakeAsync().run((async) { + Timer(const Duration(days: 1), () {}); + expect(async.runNextTimer(until: const Duration(hours: 1)), false); + expect(async.elapsed, Duration.zero); + }); + }); + test('should apply `until` as non-strict bound', () { FakeAsync().run((async) { var ran = false; @@ -526,8 +543,11 @@ void main() { var ran = false; Timer(const Duration(hours: 3), () => ran = true); async.elapse(const Duration(hours: 1)); + expect(async.runNextTimer(until: const Duration(hours: 2)), false); expect(ran, false); + expect(async.elapsed, const Duration(hours: 1)); + expect(async.runNextTimer(until: const Duration(hours: 3)), true); expect(ran, true); }); From 6fb6a8c9eaa2e1dc701fd087ef691be7b11212f7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Oct 2024 18:06:49 -0700 Subject: [PATCH 10/10] Document possible re-entrancy on runNextTimer --- lib/fake_async.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/fake_async.dart b/lib/fake_async.dart index 0a19361..4e7511f 100644 --- a/lib/fake_async.dart +++ b/lib/fake_async.dart @@ -247,7 +247,12 @@ class FakeAsync { /// /// Because multiple timers may be due at the same time, a call to this /// method may leave the time advanced to where other timers are due. - /// Doing an `elapse(Duration.zero)` afterwards may trigger more timers. + /// Calling `elapse(Duration.zero)` afterwards may trigger more timers. + /// + /// If microtasks or timer callbacks make their own calls to methods on this + /// [FakeAsync], then a call to this method may indirectly cause more timers + /// to run beyond the timer it runs directly, and may cause [elapsed] to + /// advance beyond [until]. Any such timers are ignored in the return value. /// /// Returns `true` if a timer was run, `false` otherwise. bool runNextTimer({Duration? until}) {