blob: 3767d50eca17707b67e13296a951b41b17e72411 [file] [log] [blame]
// Copyright 2014 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
part of quiver.testing.async;
/// A mechanism to make time-dependent units testable.
///
/// Test code can be passed as a callback to [run], which causes it to be run in
/// a [Zone] which fakes timer and microtask creation, such that they are run
/// during calls to [elapse] which simulates the asynchronous passage of time.
///
/// The synchronous passage of time (blocking or expensive calls) can also be
/// simulated using [elapseBlocking].
///
/// To allow the unit under test to tell time, it can receive a [Clock] as a
/// dependency, and default it to [const Clock()] in production, but then use
/// [clock] in test code.
///
/// Example:
///
/// test('testedFunc', () {
/// new FakeAsync().run((async) {
/// testedFunc(clock: async.getClock(initialTime));
/// async.elapse(duration);
/// expect(...)
/// });
/// });
abstract class FakeAsync {
factory FakeAsync() = _FakeAsync;
/// Returns a fake [Clock] whose time can is elapsed by calls to [elapse] and
/// [elapseBlocking].
///
/// The returned clock starts at [initialTime], and calls to [elapse] and
/// [elapseBlocking] advance the clock, even if they occured before the call
/// to this method.
///
/// The clock can be passed as a dependency to the unit under test.
Clock getClock(DateTime initialTime);
/// Simulates the asynchronous passage of time.
///
/// **This should only be called from within the zone used by [run].**
///
/// If [duration] is negative, the returned future completes with an
/// [ArgumentError].
///
/// If a previous call to [elapse] has not yet completed, throws a
/// [StateError].
///
/// Any Timers created within the zone used by [run] which are to expire
/// at or before the new time after [duration] has elapsed are run.
/// The microtask queue is processed surrounding each timer. When a timer is
/// run, the [clock] will have been advanced by the timer's specified
/// duration. Calls to [elapseBlocking] from within these timers and
/// microtasks which cause the [clock] to elapse more than the specified
/// [duration], can cause more timers to expire and thus be called.
///
/// Once all expired timers are processed, the [clock] is advanced (if
/// necessary) to the time this method was called + [duration].
void elapse(Duration duration);
/// Simulates the synchronous passage of time, resulting from blocking or
/// expensive calls.
///
/// Neither timers nor microtasks are run during this call. Upon return, the
/// [clock] will have been advanced by [duration].
///
/// If [duration] is negative, throws an [ArgumentError].
void elapseBlocking(Duration duration);
/// Runs [callback] in a [Zone] with fake timer and microtask scheduling.
///
/// Uses
/// [ZoneSpecification.createTimer], [ZoneSpecification.createPeriodicTimer],
/// and [ZoneSpecification.scheduleMicrotask] to store callbacks for later
/// execution within the zone via calls to [elapse].
///
/// Calls [callback] with `this` as argument and returns the result returned
/// by [callback].
dynamic run(callback(FakeAsync self));
/// Runs all remaining microtasks, including those scheduled as a result of
/// running them, until there are no more microtasks scheduled.
///
/// Does not run timers.
void flushMicrotasks();
/// Runs all timers until no timers remain (subject to [flushPeriodicTimers]
/// option), including those scheduled as a result of running them.
///
/// [timeout] lets you set the maximum amount of time the flushing will take.
/// Throws a [StateError] if the [timeout] is exceeded. The default timeout
/// is 1 hour. [timeout] is relative to the elapsed time.
void flushTimers(
{Duration timeout: const Duration(hours: 1),
bool flushPeriodicTimers: true});
/// The number of created periodic timers that have not been canceled.
int get periodicTimerCount;
/// The number of pending non periodic timers that have not been canceled.
int get nonPeriodicTimerCount;
/// The number of pending microtasks.
int get microtaskCount;
}
class _FakeAsync implements FakeAsync {
Duration _elapsed = Duration.zero;
Duration _elapsingTo;
Queue<Function> _microtasks = new Queue();
Set<_FakeTimer> _timers = new Set<_FakeTimer>();
@override
Clock getClock(DateTime initialTime) =>
new Clock(() => initialTime.add(_elapsed));
@override
void elapse(Duration duration) {
if (duration.inMicroseconds < 0) {
throw new ArgumentError('Cannot call elapse with negative duration');
}
if (_elapsingTo != null) {
throw new StateError('Cannot elapse until previous elapse is complete.');
}
_elapsingTo = _elapsed + duration;
_drainTimersWhile((_FakeTimer next) => next._nextCall <= _elapsingTo);
_elapseTo(_elapsingTo);
_elapsingTo = null;
}
@override
void elapseBlocking(Duration duration) {
if (duration.inMicroseconds < 0) {
throw new ArgumentError('Cannot call elapse with negative duration');
}
_elapsed += duration;
if (_elapsingTo != null && _elapsed > _elapsingTo) {
_elapsingTo = _elapsed;
}
}
@override
void flushMicrotasks() {
_drainMicrotasks();
}
@override
void flushTimers(
{Duration timeout: const Duration(hours: 1),
bool flushPeriodicTimers: true}) {
final absoluteTimeout = _elapsed + timeout;
_drainTimersWhile((_FakeTimer timer) {
if (timer._nextCall > absoluteTimeout) {
throw new StateError(
'Exceeded timeout ${timeout} while flushing timers');
}
if (flushPeriodicTimers) {
return _timers.isNotEmpty;
} else {
// translation: drain every timer (periodic or not) that will occur up
// until the latest non-periodic timer
return _timers.any((_FakeTimer timer) =>
!timer._isPeriodic || timer._nextCall <= _elapsed);
}
});
}
@override
run(callback(FakeAsync self)) {
if (_zone == null) {
_zone = Zone.current.fork(specification: _zoneSpec);
}
var result;
_zone.runGuarded(() {
result = callback(this);
});
return result;
}
Zone _zone;
@override
int get periodicTimerCount =>
_timers.where((_FakeTimer timer) => timer._isPeriodic).length;
@override
int get nonPeriodicTimerCount =>
_timers.where((_FakeTimer timer) => !timer._isPeriodic).length;
@override
int get microtaskCount => _microtasks.length;
ZoneSpecification get _zoneSpec => new ZoneSpecification(
createTimer: (_, __, ___, Duration duration, Function callback) {
return _createTimer(duration, callback, false);
}, createPeriodicTimer:
(_, __, ___, Duration duration, Function callback) {
return _createTimer(duration, callback, true);
}, scheduleMicrotask: (_, __, ___, Function microtask) {
_microtasks.add(microtask);
});
_drainTimersWhile(bool predicate(_FakeTimer timer)) {
_drainMicrotasks();
_FakeTimer next;
while ((next = _getNextTimer()) != null && predicate(next)) {
_runTimer(next);
_drainMicrotasks();
}
}
_elapseTo(Duration to) {
if (to > _elapsed) {
_elapsed = to;
}
}
Timer _createTimer(Duration duration, Function callback, bool isPeriodic) {
var timer = new _FakeTimer._(duration, callback, isPeriodic, this);
_timers.add(timer);
return timer;
}
_FakeTimer _getNextTimer() {
return _timers.isEmpty
? null
: _timers.reduce((t1, t2) => t1._nextCall <= t2._nextCall ? t1 : t2);
}
_runTimer(_FakeTimer timer) {
assert(timer.isActive);
_elapseTo(timer._nextCall);
if (timer._isPeriodic) {
timer._callback(timer);
timer._nextCall += timer._duration;
} else {
_timers.remove(timer);
timer._callback();
}
}
_drainMicrotasks() {
while (_microtasks.isNotEmpty) {
_microtasks.removeFirst()();
}
}
_hasTimer(_FakeTimer timer) => _timers.contains(timer);
_cancelTimer(_FakeTimer timer) => _timers.remove(timer);
}
class _FakeTimer implements Timer {
final Duration _duration;
final Function _callback;
final bool _isPeriodic;
final _FakeAsync _time;
Duration _nextCall;
// TODO: In browser JavaScript, timers can only run every 4 milliseconds once
// sufficiently nested:
// http://www.w3.org/TR/html5/webappapis.html#timer-nesting-level
// Without some sort of delay this can lead to infinitely looping timers.
// What do the dart VM and dart2js timers do here?
static const _minDuration = Duration.zero;
_FakeTimer._(Duration duration, this._callback, this._isPeriodic, this._time)
: _duration = duration < _minDuration ? _minDuration : duration {
_nextCall = _time._elapsed + _duration;
}
bool get isActive => _time._hasTimer(this);
cancel() => _time._cancelTimer(this);
@override
// TODO: Dart 2.0 requires this method to be implemented.
// ignore: override_on_non_overriding_getter
int get tick {
throw new UnimplementedError("tick");
}
}