diff --git a/package.json b/package.json index ad6ce22cee48..67999eaa7fab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "bluebird": "~2.0.7", "connect": "~2.19.5", "event-stream": "~3.1.5", - "expect.js": "~0.2.0", + "expect.js": "~0.3.1", "grunt": "~0.4.5", "grunt-contrib-clean": "~0.5.0", "grunt-contrib-compress": "~0.9.1", diff --git a/src/kibana/components/reflow_watcher.js b/src/kibana/components/reflow_watcher.js new file mode 100644 index 000000000000..6b91c1f60ccf --- /dev/null +++ b/src/kibana/components/reflow_watcher.js @@ -0,0 +1,68 @@ +define(function (require) { + return function ReflowWatcherService(Private, $rootScope, $http) { + var angular = require('angular'); + var $ = require('jquery'); + var _ = require('lodash'); + + var EventEmitter = Private(require('factories/events')); + var $body = $(document.body); + var $window = $(window); + + var MOUSE_EVENTS = 'mouseup'; + var WINDOW_EVENTS = 'resize'; + + _(ReflowWatcher).inherits(EventEmitter); + /** + * Watches global activity which might hint at a change in the content, which + * in turn provides a hint to resizers that they should check their size + */ + function ReflowWatcher() { + ReflowWatcher.Super.call(this); + + // bound version of trigger that can be used as a handler + this.trigger = _.bind(this.trigger, this); + this._emitReflow = _.bind(this._emitReflow, this); + + // list of functions to call that will unbind our watchers + this._unwatchers = [ + $rootScope.$watchCollection(function () { + return $http.pendingRequests; + }, this.trigger) + ]; + + $body.on(MOUSE_EVENTS, this.trigger); + $window.on(WINDOW_EVENTS, this.trigger); + } + + /** + * Simply emit reflow, but in a way that can be bound and passed to + * other functions. Using _.bind caused extra arguments to be added, and + * then emitted to other places. No Bueno + * + * @return {void} + */ + ReflowWatcher.prototype._emitReflow = function () { + this.emit('reflow'); + }; + + /** + * Emit the "reflow" event in the next tick of the digest cycle + * @return {void} + */ + ReflowWatcher.prototype.trigger = function () { + $rootScope.$evalAsync(this._emitReflow); + }; + + /** + * Signal to the ReflowWatcher that it should clean up it's listeners + * @return {void} + */ + ReflowWatcher.prototype.destroy = function () { + $body.off(MOUSE_EVENTS, this.trigger); + $window.off(WINDOW_EVENTS, this.trigger); + _.callEach(this._unwatchers); + }; + + return new ReflowWatcher(); + }; +}); \ No newline at end of file diff --git a/src/kibana/components/vislib/lib/resize_checker.js b/src/kibana/components/vislib/lib/resize_checker.js new file mode 100644 index 000000000000..36ae3e25f64a --- /dev/null +++ b/src/kibana/components/vislib/lib/resize_checker.js @@ -0,0 +1,209 @@ +define(function (require) { + return function ResizeCheckerFactory(Private, Notifier) { + var $ = require('jquery'); + var _ = require('lodash'); + + var EventEmitter = Private(require('factories/events')); + var reflowWatcher = Private(require('components/reflow_watcher')); + var sequencer = require('utils/sequencer'); + + var SCHEDULE_LONG = ResizeChecker.SCHEDULE_LONG = sequencer.createEaseOut( + 250, // shortest delay + 10000, // longest delay + 150 // tick count + ); + + var SCHEDULE_SHORT = ResizeChecker.SCHEDULE_SHORT = sequencer.createEaseIn( + 5, // shortest delay + 500, // longest delay + 100 // tick count + ); + + // maximum ms that we can delay emitting 'resize'. This is only used + // to debounce resizes when the size of the element is constantly changing + var MS_MAX_RESIZE_DELAY = ResizeChecker.MS_MAX_RESIZE_DELAY = 500; + + /** + * Checks the size of an element on a regular basis. Provides + * an event that is emited when the element has changed size. + * + * @class ResizeChecker + * @param {HtmlElement} el - the element to track the size of + */ + _(ResizeChecker).inherits(EventEmitter); + function ResizeChecker(el) { + ResizeChecker.Super.call(this); + + this.$el = $(el); + this.notify = new Notifier({ location: 'Vislib ResizeChecker ' + _.uniqueId() }); + + this.saveSize(); + + this.check = _.bind(this.check, this); + this.check(); + + this.onReflow = _.bind(this.onReflow, this); + reflowWatcher.on('reflow', this.onReflow); + } + + ResizeChecker.prototype.onReflow = function () { + this.startSchedule(SCHEDULE_LONG); + }; + + /** + * Read the size of the element + * + * @method read + * @return {object} - an object with keys `w` (width) and `h` (height) + */ + ResizeChecker.prototype.read = function () { + return { + w: this.$el.width(), + h: this.$el.height() + }; + }; + + + /** + * Save the element size, preventing it from being considered as an + * update. + * + * @method save + * @param {object} [size] - optional size to save, otherwise #read() is called + * @return {boolean} - true if their was a change in the new + */ + ResizeChecker.prototype.saveSize = function (size) { + if (!size) size = this.read(); + + if (this._equalsSavedSize(size)) { + return false; + } + + this._savedSize = size; + return true; + }; + + + /** + * Determine if a given size matches the currently saved size. + * + * @private + * @method _equalsSavedSize + * @param {object} a - an object that matches the return value of #read() + * @return {boolean} - true if the passed in value matches the saved size + */ + ResizeChecker.prototype._equalsSavedSize = function (a) { + var b = this._savedSize || {}; + return a.w === b.w && a.h === b.h; + }; + + /** + * Read the time that the dirty state last changed. + * + * @method lastDirtyChange + * @return {timestamp} - the unix timestamp (in ms) of the last update + * to the dirty state + */ + ResizeChecker.prototype.lastDirtyChange = function () { + return this._dirtyChangeStamp; + }; + + /** + * Record the dirty state + * + * @method saveDirty + * @param {boolean} val + * @return {boolean} - true if the dirty state changed by this save + */ + ResizeChecker.prototype.saveDirty = function (val) { + val = !!val; + + if (val === this._isDirty) return false; + + this._isDirty = val; + this._dirtyChangeStamp = Date.now(); + return true; + }; + + /** + * The check routine that executes regularly and will reschedule itself + * to run again in the future. It determines the state of the elements + * size and decides when to emit the "update" event. + * + * @method check + * @return {void} + */ + ResizeChecker.prototype.check = function () { + var newSize = this.read(); + var dirty = this.saveSize(newSize); + var dirtyChanged = this.saveDirty(dirty); + + var doneDirty = !dirty && dirtyChanged; + var muchDirty = dirty && (this.lastDirtyChange() - Date.now() > MS_MAX_RESIZE_DELAY); + if (doneDirty || muchDirty) { + this.emit('resize', newSize); + } + + // if the dirty state is unchanged, continue using the previous schedule + if (!dirtyChanged) { + return this.continueSchedule(); + } + + // when the state changes start a new schedule. Use a schedule that quickly + // slows down if it is unknown wether there are will be additional changes + return this.startSchedule(dirty ? SCHEDULE_SHORT : SCHEDULE_LONG); + }; + + /** + * Start running a new schedule, using one of the SCHEDULE_* constants. + * + * @method startSchedule + * @param {integer[]} schedule - an array of millisecond times that should + * be used to schedule calls to #check(); + * @return {integer} - the id of the next timer + */ + ResizeChecker.prototype.startSchedule = function (schedule) { + this._tick = -1; + this._currentSchedule = schedule; + return this.continueSchedule(); + }; + + /** + * Continue running the current schedule. MUST BE CALLED AFTER #startSchedule() + * + * @method continueSchedule + * @return {integer} - the id of the next timer + */ + ResizeChecker.prototype.continueSchedule = function () { + clearTimeout(this._timerId); + + if (this._tick < this._currentSchedule.length - 1) { + // at the end of the schedule, don't progress any further but repeat the last value + this._tick += 1; + } + + var check = this.check; // already bound + var tick = this._tick; + var notify = this.notify; + var ms = this._currentSchedule[this._tick]; + return (this._timerId = setTimeout(function () { + check(); + }, ms)); + }; + + /** + * Signal that the ResizeChecker should shutdown. + * + * Cleans up it's listeners and timers. + * + * @method destroy + * @return {void} + */ + ResizeChecker.prototype.destroy = function () { + reflowWatcher.off('reflow', this.check); + clearTimeout(this._timerId); + }; + + return ResizeChecker; + }; +}); \ No newline at end of file diff --git a/src/kibana/components/vislib/vis.js b/src/kibana/components/vislib/vis.js index 4d36b6703fe8..e6a492435c0e 100644 --- a/src/kibana/components/vislib/vis.js +++ b/src/kibana/components/vislib/vis.js @@ -4,6 +4,7 @@ define(function (require) { var _ = require('lodash'); var Handler = Private(require('components/vislib/lib/handler')); + var ResizeChecker = Private(require('components/vislib/lib/resize_checker')); var Events = Private(require('factories/events')); var chartTypes = Private(require('components/vislib/vis_types')); @@ -24,6 +25,12 @@ define(function (require) { this.el = $el.get ? $el.get(0) : $el; this.ChartClass = chartTypes[config.type]; this._attr = _.defaults(config || {}, {}); + + // bind the resize function so it can be used as an event handler + this.resize = _.bind(this.resize, this); + + this.resizeChecker = new ResizeChecker(this.el); + this.resizeChecker.on('resize', this.resize); } // Exposed API for rendering charts. @@ -49,24 +56,8 @@ define(function (require) { console.group(error.message); } } - - this.checkSize(); }; - // Check for changes to the chart container height and width. - Vis.prototype.checkSize = _.debounce(function () { - if (arguments.length) { return; } - - // enable auto-resize - var size = $(this.el).find('.chart').width() + ':' + $(this.el).find('.chart').height(); - - if (this.prevSize !== size) { - this.resize(); - } - this.prevSize = size; - setTimeout(this.checkSize(), 250); - }, 250); - // Resize the chart Vis.prototype.resize = function () { if (!this.data) { @@ -78,16 +69,14 @@ define(function (require) { // Destroy the chart Vis.prototype.destroy = function () { - // Turn off checkSize - this.checkSize(false); - // Removing chart and all elements associated with it d3.select(this.el).selectAll('*').remove(); - // Cleaning up event listeners - this.off('click', null); - this.off('hover', null); - this.off('brush', null); + // remove event listeners + this.resizeChecker.off('resize', this.resize); + + // pass destroy call down to owned objects + this.resizeChecker.destroy(); }; // Set attributes on the chart diff --git a/src/kibana/utils/_mixins.js b/src/kibana/utils/_mixins.js index bec42fd00b28..35a5a51b3bb2 100644 --- a/src/kibana/utils/_mixins.js +++ b/src/kibana/utils/_mixins.js @@ -150,6 +150,15 @@ define(function (require) { // always call flush, it might not do anything flush(this, args); }; + }, + chunk: function (arr, count) { + var size = Math.ceil(arr.length / count); + var chunks = new Array(count); + for (var i = 0; i < count; i ++) { + var start = i * size; + chunks[i] = arr.slice(start, start + size); + } + return chunks; } }); diff --git a/src/kibana/utils/sequencer.js b/src/kibana/utils/sequencer.js new file mode 100644 index 000000000000..28189cac3858 --- /dev/null +++ b/src/kibana/utils/sequencer.js @@ -0,0 +1,95 @@ +define(function (require) { + var _ = require('lodash'); + + function create(min, max, length, mod) { + var seq = new Array(length); + + var valueDist = max - min; + + // range of values that the mod creates + var modRange = [mod(0, length), mod(length - 1, length)]; + + // distance between + var modRangeDist = modRange[1] - modRange[0]; + + _.times(length, function (i) { + var modIPercent = (mod(i, length) - modRange[0]) / modRangeDist; + + // percent applied to distance and added to min to + // produce value + seq[i] = min + (valueDist * modIPercent); + }); + + seq.min = min; + seq.max = max; + + return seq; + } + + return { + /** + * Create an exponential sequence of numbers. + * + * Creates a curve resembling: + * + * ; + * / + * / + * .-' + * _.-" + * _.-'" + * _,.-'" + * _,..-'" + * _,..-'"" + * _,..-'"" + * ____,..--'"" + * + * @param {number} min - the min value to produce + * @param {number} max - the max value to produce + * @param {number} length - the number of values to produce + * @return {number[]} - an array containing the sequence + */ + createEaseIn: _.partialRight(create, function (i, length) { + // generates numbers from 1 to +Infinity + return i * Math.pow(i, 1.1111); + }), + + /** + * Create an sequence of numbers using sine. + * + * Create a curve resembling: + * + * ____,..--'"" + * _,..-'"" + * _,..-'"" + * _,..-'" + * _,.-'" + * _.-'" + * _.-" + * .-' + * / + * / + * ; + * + * + * @param {number} min - the min value to produce + * @param {number} max - the max value to produce + * @param {number} length - the number of values to produce + * @return {number[]} - an array containing the sequence + */ + createEaseOut: _.partialRight(create, function (i, length) { + // adapted from output of http://www.timotheegroleau.com/Flash/experiments/easing_function_generator.htm + // generates numbers from 0 to 100 + + var ts = (i /= length) * i; + var tc = ts * i; + return 100 * ( + 0.5 * tc * ts + + -3 * ts * ts + + 6.5 * tc + + -7 * ts + + 4 * i + ); + }) + }; +}); \ No newline at end of file diff --git a/test/unit/index.html b/test/unit/index.html index c1d8724ec15f..53ea56d19fba 100644 --- a/test/unit/index.html +++ b/test/unit/index.html @@ -3,7 +3,7 @@ Kibana4 Tests - + @@ -79,6 +79,7 @@ 'specs/utils/interval', 'specs/utils/versionmath', 'specs/utils/routes/index', + 'specs/utils/sequencer', 'specs/courier/search_source/_get_normalized_sort', 'specs/factories/base_object', 'specs/state_management/state', @@ -103,13 +104,15 @@ 'specs/vislib/handler', 'specs/vislib/_error_handler', 'specs/vislib/data', + 'specs/vislib/resize_checker', 'specs/utils/diff_time_picker_vals', 'specs/factories/events', 'specs/index_patterns/_flatten_search_response', 'specs/utils/registry/index', 'specs/directives/filter_bar', 'specs/components/agg_types/index', - 'specs/components/vis/index' + 'specs/components/vis/index', + 'specs/components/reflow_watcher' ], function (kibana, sinon) { kibana.load(function () { var xhr = sinon.useFakeXMLHttpRequest(); diff --git a/test/unit/specs/components/reflow_watcher.js b/test/unit/specs/components/reflow_watcher.js new file mode 100644 index 000000000000..6243ff23a65e --- /dev/null +++ b/test/unit/specs/components/reflow_watcher.js @@ -0,0 +1,78 @@ +define(function (require) { + describe('Reflow watcher', function () { + require('angular'); + var $ = require('jquery'); + var _ = require('lodash'); + var sinon = require('test_utils/auto_release_sinon'); + + var $body = $(document.body); + var $window = $(window); + var expectStubbedEventAndEl = function (stub, event, $el) { + expect(stub.getCalls().some(function (call) { + var events = call.args[0].split(' '); + return _.contains(events, event) && $el.is(call.thisValue); + })).to.be(true); + }; + + var EventEmitter; + var reflowWatcher; + var $rootScope; + var $onStub; + + beforeEach(module('kibana')); + beforeEach(inject(function (Private, $injector) { + $rootScope = $injector.get('$rootScope'); + EventEmitter = Private(require('factories/events')); + + // stub jQuery's $.on method while creating the reflowWatcher + $onStub = sinon.stub($.fn, 'on'); + reflowWatcher = Private(require('components/reflow_watcher')); + $onStub.restore(); + + // setup the reflowWatchers $http watcher + $rootScope.$apply(); + })); + + it('is an event emitter', function () { + expect(reflowWatcher).to.be.an(EventEmitter); + }); + + describe('listens', function () { + it('to "mouseup" on the body', function () { + expectStubbedEventAndEl($onStub, 'mouseup', $body); + }); + + it('to "resize" on the window', function () { + expectStubbedEventAndEl($onStub, 'resize', $window); + }); + }); + + describe('un-listens in #destroy()', function () { + var $offStub; + + beforeEach(function () { + $offStub = sinon.stub($.fn, 'off'); + reflowWatcher.destroy(); + $offStub.restore(); + }); + + it('to "mouseup" on the body', function () { + expectStubbedEventAndEl($offStub, 'mouseup', $body); + }); + + it('to "resize" on the window', function () { + expectStubbedEventAndEl($offStub, 'resize', $window); + }); + }); + + it('triggers the "reflow" event within a new angular tick', function () { + var stub = sinon.stub(); + reflowWatcher.on('reflow', stub); + reflowWatcher.trigger(); + + expect(stub).to.have.property('callCount', 0); + $rootScope.$apply(); + expect(stub).to.have.property('callCount', 1); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/specs/utils/sequencer.js b/test/unit/specs/utils/sequencer.js new file mode 100644 index 000000000000..cec9ff645f56 --- /dev/null +++ b/test/unit/specs/utils/sequencer.js @@ -0,0 +1,101 @@ +define(function (require) { + describe('sequencer util', function () { + var _ = require('lodash'); + var sequencer = require('utils/sequencer'); + + var args = [ + { min: 500, max: 7500, length: 1500 }, + { min: 50, max: 500, length: 1000 }, + { min: 5, max: 50, length: 100 } + ]; + + function eachSeqFor(method, fn) { + args.forEach(function (args) { + fn(method(args.min, args.max, args.length), args); + }); + } + + function getSlopes(seq, count) { + return _.chunk(seq, count).map(function (chunk) { + return (_.last(chunk) - _.first(chunk)) / chunk.length; + }); + } + + // using expect() here causes massive GC runs because seq can be +1000 elements + function expectedChange(seq, up) { + up = !!up; + + if (seq.length < 2) { + throw new Error('unable to reach change without at least two elements'); + } + + seq.forEach(function (n, i) { + if (i > 0 && (seq[i - 1] < n) !== up) { + throw new Error('expected values to ' + (up ? 'increase': 'decrease')); + } + }); + } + + function generalTests(seq, args) { + it('obeys the min arg', function () { + expect(Math.min.apply(Math, seq)).to.be(args.min); + }); + + it('obeys the max arg', function () { + expect(Math.max.apply(Math, seq)).to.be(args.max); + }); + + it('obeys the length arg', function () { + expect(seq).to.have.length(args.length); + }); + + it('always creates increasingly larger values', function () { + expectedChange(seq, true); + }); + } + + describe('#createEaseIn', function () { + eachSeqFor(sequencer.createEaseIn, function (seq, args) { + describe('with args: ' + JSON.stringify(args), function () { + generalTests(seq, args); + + it('produces increasing slopes', function () { + expectedChange(getSlopes(seq, 2), true); + expectedChange(getSlopes(seq, 4), true); + expectedChange(getSlopes(seq, 6), true); + }); + }); + }); + }); + + describe('#createEaseOut', function () { + eachSeqFor(sequencer.createEaseOut, function (seq, args) { + describe('with args: ' + JSON.stringify(args), function () { + generalTests(seq, args); + + it('produces decreasing slopes', function () { + expectedChange(getSlopes(seq, 2), false); + expectedChange(getSlopes(seq, 4), false); + expectedChange(getSlopes(seq, 6), false); + }); + + // Flipped version of previous test to ensure that expectedChange() + // and friends are behaving properly + it('doesn\'t produce increasing slopes', function () { + expect(function () { + expectedChange(getSlopes(seq, 2), true); + }).to.throwError(); + + expect(function () { + expectedChange(getSlopes(seq, 4), true); + }).to.throwError(); + + expect(function () { + expectedChange(getSlopes(seq, 6), true); + }).to.throwError(); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/specs/vislib/handler.js b/test/unit/specs/vislib/handler.js index a7c2d8a52f4c..118e90c16361 100644 --- a/test/unit/specs/vislib/handler.js +++ b/test/unit/specs/vislib/handler.js @@ -104,7 +104,7 @@ define(function (require) { addLegend: true }; - vis = new Vis(el, config); + vis = new Vis(el[0][0], config); handler = new Handler({ vis: vis, diff --git a/test/unit/specs/vislib/resize_checker.js b/test/unit/specs/vislib/resize_checker.js new file mode 100644 index 000000000000..7709ed066784 --- /dev/null +++ b/test/unit/specs/vislib/resize_checker.js @@ -0,0 +1,199 @@ +define(function (require) { + describe('Vislib Resize Checker', function () { + var $ = require('jquery'); + var _ = require('lodash'); + var Promise = require('bluebird'); + + var sinon = require('test_utils/auto_release_sinon'); + require('test_utils/no_digest_promises').activateForSuite(); + + var ResizeChecker; + var EventEmitter; + var checker; + var reflowWatcher; + var spyReflowOn; + + beforeEach(module('kibana')); + beforeEach(inject(function (Private) { + ResizeChecker = Private(require('components/vislib/lib/resize_checker')); + EventEmitter = Private(require('factories/events')); + reflowWatcher = Private(require('components/reflow_watcher')); + + spyReflowOn = sinon.spy(reflowWatcher, 'on'); + checker = new ResizeChecker( + $(document.createElement('div')) + .appendTo('body') + .css('visibility', 'hidden') + .get(0) + ); + spyReflowOn.restore(); + })); + + afterEach(function () { + checker.$el.remove(); + checker.destroy(); + }); + + it('is an event emitter', function () { + expect(checker).to.be.a(EventEmitter); + }); + + it('emits a "resize" event when the el is resized', function (done) { + checker.on('resize', function () { + done(); + }); + + checker.$el.text('haz contents'); + checker.check(); + }); + + it('listens for the "reflow" event of the reflowWatchers', function () { + expect(spyReflowOn).to.have.property('callCount', 1); + var call = spyReflowOn.getCall(0); + expect(call.args[0]).to.be('reflow'); + }); + + describe('#read', function () { + it('uses jquery to get the width and height of the element', function () { + var stubw = sinon.spy($.fn, 'width'); + var stubh = sinon.spy($.fn, 'height'); + + checker.read(); + + expect(stubw).to.have.property('callCount', 1); + expect(stubw.getCall(0)).to.have.property('thisValue', checker.$el); + + expect(stubh).to.have.property('callCount', 1); + expect(stubh.getCall(0)).to.have.property('thisValue', checker.$el); + }); + }); + + describe('#saveSize', function () { + it('calls #read() when no arg is passed', function () { + var stub = sinon.stub(checker, 'read').returns({}); + + checker.saveSize(); + + expect(stub).to.have.property('callCount', 1); + }); + + it('saves the size of the element', function () { + var football = {}; + checker.saveSize(football); + expect(checker).to.have.property('_savedSize', football); + }); + + it('returns false if the size matches the previous value', function () { + expect(checker.saveSize(checker._savedSize)).to.be(false); + }); + + it('returns true if the size is different than previous value', function () { + expect(checker.saveSize({})).to.be(true); + }); + }); + + describe('#check()', function () { + var emit; + + beforeEach(function () { + emit = sinon.stub(checker, 'emit'); + + // prevent the checker from auto-checking + checker.destroy(); + checker.startSchedule = checker.continueSchedule = _.noop; + }); + + it('does not emit "resize" immediately after a resize, but waits for changes to stop', function () { + expect(checker).to.have.property('_isDirty', false); + + checker.$el.css('height', 100); + checker.check(); + + expect(checker).to.have.property('_isDirty', true); + expect(emit).to.have.property('callCount', 0); + + // no change in el size + checker.check(); + + expect(checker).to.have.property('_isDirty', false); + expect(emit).to.have.property('callCount', 1); + }); + + it('emits "resize" based on MS_MAX_RESIZE_DELAY, even if el\'s constantly changing size', function () { + var steps = _.random(5, 10); + this.slow(steps * 10); + + // we are going to fake the delay using the fake clock + var msStep = Math.floor(ResizeChecker.MS_MAX_RESIZE_DELAY / (steps - 1)); + var clock = sinon.useFakeTimers(); + + _.times(steps, function step(i) { + checker.$el.css('height', 100 + i); + checker.check(); + + expect(checker).to.have.property('_isDirty', true); + expect(emit).to.have.property('callCount', i > steps ? 1 : 0); + + clock.tick(msStep); // move the clock forward one step + }); + + }); + }); + + describe('scheduling', function () { + var clock; + var schedule; + + beforeEach(function () { + // prevent the checker from running automatically + checker.destroy(); + clock = sinon.useFakeTimers(); + + schedule = []; + _.times(25, function () { + schedule.push(_.random(3, 250)); + }); + }); + + it('walks the schedule, using each value as it\'s next timeout', function () { + var timerId = checker.startSchedule(schedule); + + // start at 0 even though "start" used the first slot, we will still check it + for (var i = 0; i < schedule.length; i++) { + expect(clock.timeouts[timerId]).to.have.property('callAt', schedule[i]); + timerId = checker.continueSchedule(); + } + }); + + it('repeats the last value in the schedule', function () { + var timerId = checker.startSchedule(schedule); + + // start at 1, and go until there is one left + for (var i = 1; i < schedule.length - 1; i++) { + timerId = checker.continueSchedule(); + } + + var last = _.last(schedule); + _.times(5, function () { + var timer = clock.timeouts[checker.continueSchedule()]; + expect(timer).to.have.property('callAt', last); + }); + }); + }); + + describe('#destroy()', function () { + it('removes the "reflow" event from the reflowWatcher', function () { + var stub = sinon.stub(reflowWatcher, 'off'); + checker.destroy(); + expect(stub).to.have.property('callCount', 1); + expect(stub.calledWith('reflow')).to.be.ok(); + }); + + it('clears the timeout', function () { + var spy = sinon.spy(window, 'clearTimeout'); + checker.destroy(); + expect(spy).to.have.property('callCount', 1); + }); + }); + }); +}); \ No newline at end of file