diff --git a/packages/xo-collection/collection.buffer.spec.js b/packages/xo-collection/collection.buffer.spec.js new file mode 100644 index 000000000..76cd2344d --- /dev/null +++ b/packages/xo-collection/collection.buffer.spec.js @@ -0,0 +1,331 @@ +var chai = require('chai'); +var expect = chai.expect; +var dirtyChai = require('dirty-chai'); +chai.use(dirtyChai); +var leche = require('leche'); +var sinon = require('sinon'); + +var Collection = require('./collection'); + +describe('collection buffer', function () { + + // ============================================================ + + var col; + + var addSpy, addCount; + var updateSpy, updateCount; + var removeSpy, removeCount; + var flush; + + before(function () { + + col = new Collection.Collection(); + + addSpy = sinon.spy(); + addCount = 0; + updateSpy = sinon.spy(); + updateCount = 0; + removeSpy = sinon.spy(); + removeCount = 0; + + }); + + after(function () { + + col.removeAllListeners(); + + }); + + beforeEach(/*'force flush', */function () { + + col.removeAllListeners(); // flush may emit events + + try { // In case test cases interrupt before flushing + if (flush) { + flush(); + } + } catch(e) { + if (!e instanceof Collection.NotBuffering) { + throw e; + } + } + + col.on('add', addSpy); + col.on('update', updateSpy); + col.on('remove', removeSpy); + + }); + + // Collection is empty ======================================================= + + var data1 = { + foo: 1, + bar: [1, 2], + baz: {a:1, b:2} + }; + + describe('add', function () { + + + it('Emits no event when buffered, all data is emitted when flushed', function () { + + flush = col.bufferChanges(); + + expect(addSpy.callCount).to.eq(addCount); + + for (var prop in data1) { + expect(col.add(prop, data1[prop])).to.eq(col); + } + + expect(addSpy.callCount).to.eq(addCount); + + flush(); + addCount++; + + expect(addSpy.callCount).to.eq(addCount); + expect(addSpy.calledWith(data1)).to.be.true(); + + expect(flush).to.throw(Collection.NotBuffering); + + }); + + }); + + var data2 = { + foo: 3, + bar: [3, 4], + baz: {c:3, d:4} + }; + + var removedKeysData2 = { + foo: null, + bar: null, + baz: null + }; + + describe('update', function () { + + + it('Emits no event when buffered, all data is emitted when flushed', function () { + + flush = col.bufferChanges(); + + expect(updateSpy.callCount).to.eq(updateCount); + + for (var prop in data2) { + expect(col.update(prop, data2[prop])).to.eq(col); + } + + expect(updateSpy.callCount).to.eq(updateCount); + + flush(); + updateCount++; + + expect(updateSpy.callCount).to.eq(addCount); + expect(updateSpy.calledWith(data2)).to.be.true(); + + expect(flush).to.throw(Collection.NotBuffering); + + }); + + }); + + + + describe('remove', function () { + + + it('Emits no event when buffered, removed keys are emitted when flushed', function () { + + flush = col.bufferChanges(); + + expect(removeSpy.callCount).to.eq(removeCount); + + for (var prop in data2) { + expect(col.remove(prop)).to.eq(col); + } + + expect(removeSpy.callCount).to.eq(removeCount); + + flush(); + removeCount++; + + expect(removeSpy.callCount).to.eq(removeCount); + expect(removeSpy.calledWith(removedKeysData2)).to.be.true(); + + expect(flush).to.throw(Collection.NotBuffering); + + }); + + }); + + describe('buffer', function() { + + beforeEach(/*'Init collection before buffering', */function() { + + col.removeAllListeners(); + + col.clear(); + col.add('exist', 0); + col.add('disappear', 3); + + col.on('add', addSpy); + col.on('update', updateSpy); + col.on('remove', removeSpy); + }); + + leche.withData( + { + + 'add && update => add': [ + [ + {action: 'add', key: 'new', value:1}, + {action: 'update', key: 'new', value:2}, + ], + { + add: 1, + update: 0, + remove: 0 + }, + { + add: {'new': 2} + } + ], + + 'update && update => update': [ + [ + {action: 'update', key: 'exist', value:1}, + {action: 'update', key: 'exist', value:2}, + ], + { + add: 0, + update: 1, + remove: 0 + }, + { + update: {'exist': 2} + } + ], + + 'update && remove => remove': [ + [ + {action: 'update', key: 'exist', value:1}, + {action: 'remove', key: 'exist'}, + ], + { + add: 0, + update: 0, + remove: 1 + }, + { + remove: {'exist': null} + } + ], + + 'add && [update &&] remove => nothing': [ + [ + {action: 'add', key: 'new', value:1}, + {action: 'update', key: 'new', value:1}, + {action: 'remove', key: 'new'}, + ], + { + add: 0, + update: 0, + remove: 0 + }, + {} + ], + + 'remove && add => update': [ + [ + {action: 'remove', key: 'exist'}, + {action: 'add', key: 'exist', value:0} + ], + { + add: 0, + update: 1, + remove: 0 + }, + { + update: {'exist': 0} + } + ], + + 'every entry is isolated': [ + [ + {action: 'update', key: 'disappear', value:22}, + {action: 'remove', key: 'disappear'}, + {action: 'add', key: 'new', value:1}, + {action: 'update', key: 'new', value:222}, + {action: 'update', key: 'exist', value:1}, + {action: 'update', key: 'exist', value:2222}, + {action: 'add', key: 'nothing', value:0}, + {action: 'update', key: 'nothing', value:1}, + {action: 'remove', key: 'nothing'}, + + ], + { + add: 1, + update: 1, + remove: 1 + }, + { + remove: {'disappear': null}, + add: {'new': 222}, + update: {'exist': 2222} + } + ] + + + }, + + function (actions, increments, eventArgs) { + + it('Filters side effects forgotten by override', function () { + + expect(addSpy.callCount).to.eq(addCount); + expect(updateSpy.callCount).to.eq(updateCount); + expect(removeSpy.callCount).to.eq(removeCount); + + flush = col.bufferChanges(); + + actions.forEach(function (action) { + expect( + col[action.action](action.key, action.value) + ).to.eq(col); + }); + + expect(addSpy.callCount).to.eq(addCount); + expect(updateSpy.callCount).to.eq(updateCount); + expect(removeSpy.callCount).to.eq(removeCount); + + flush(); + expect(flush).to.throw(Collection.NotBuffering); + + addCount += increments.add; + updateCount += increments.update; + removeCount += increments.remove; + + expect(addSpy.callCount).to.eq(addCount); + expect(updateSpy.callCount).to.eq(updateCount); + expect(removeSpy.callCount).to.eq(removeCount); + + if (eventArgs.add) { + expect(addSpy.calledWith(eventArgs.add)).to.be.true(); + } + if (eventArgs.update) { + expect(updateSpy.calledWith(eventArgs.update)).to.be.true(); + } + if (eventArgs.remove) { + expect(removeSpy.calledWith(eventArgs.remove)).to.be.true(); + } + + }); + + } + ); + + }); + +}); diff --git a/packages/xo-collection/collection.js b/packages/xo-collection/collection.js index 2c8f14d19..c604d4269 100644 --- a/packages/xo-collection/collection.js +++ b/packages/xo-collection/collection.js @@ -20,31 +20,43 @@ class Collection extends events.EventEmitter { } - _initBuffer () { - - this._buffer = {}; - - } - bufferChanges () { - if (this._buffer) { + if (this._buffering) { throw new AlreadyBuffering('Already buffering'); // FIXME Really ?... } - this._buffer = true; + this._buffering = true; + this._buffer = {}; return () => { - if (!this._buffer) { + if (!this._buffering) { throw new NotBuffering('Nothing to flush'); // FIXME Really ? } this._buffering = false; // FIXME Really ? - // TODO Emits events for buffered changes + let data = { + add: {data: {}}, + update: {data: {}}, + remove: {data: {}} + }; - this._initBuffer(); + for (let key in this._buffer) { + data[this._buffer[key]].data[key] = this.has(key) ? + this.get(key) : + null; // 'remove' case + data[this._buffer[key]].has = true; + } + + ['add', 'update', 'remove'].forEach(action => { + if (data[action].has) { + this.emit(action, data[action].data); + } + }); + + delete this._buffer; }; @@ -52,9 +64,36 @@ class Collection extends events.EventEmitter { _touch (action, key, value) { - // TODO enable buffering + if (this._buffering) { - this.emit(action, {key: value}); + switch(action) { + + case 'add': + this._buffer[key] = this._buffer[key] ? 'update' : 'add'; + break; + case 'update': + this._buffer[key] = this._buffer[key] || 'update'; + break; + case 'remove': + switch(this._buffer[key]) { + case undefined: + case 'update': + this._buffer[key] = 'remove'; + break; + case 'add': + delete this._buffer[key]; + break; + + } + break; + } + + + } else { + + this.emit(action, {key: value}); + + } } @@ -169,6 +208,7 @@ class Collection extends events.EventEmitter { delete this._map[key]; this._size--; + // FIXME do we "emit" null in place of oldValue to harmonize with flush remove events ? this._touch('remove', key, oldValue); return this; @@ -177,7 +217,7 @@ class Collection extends events.EventEmitter { clear () { - if (this._size > 0) { + if (this._size > 0) { // FIXME Really ? this.emit('remove', this._map); }