From cbd93f450e63706af65c7d188d3420841db0e7a9 Mon Sep 17 00:00:00 2001 From: Fabrice Marsaud Date: Thu, 2 Apr 2015 15:32:08 +0200 Subject: [PATCH] Implementation seems ok. Deeper tests to come --- .../xo-collection/collection.async.spec.js | 132 +++++ .../xo-collection/collection.buffer.spec.js | 500 ------------------ .../xo-collection/collection.event.spec.js | 210 -------- packages/xo-collection/collection.js | 468 +++++++--------- packages/xo-collection/collection.spec.js | 252 --------- packages/xo-collection/package.json | 1 + 6 files changed, 329 insertions(+), 1234 deletions(-) create mode 100644 packages/xo-collection/collection.async.spec.js delete mode 100644 packages/xo-collection/collection.buffer.spec.js delete mode 100644 packages/xo-collection/collection.event.spec.js delete mode 100644 packages/xo-collection/collection.spec.js diff --git a/packages/xo-collection/collection.async.spec.js b/packages/xo-collection/collection.async.spec.js new file mode 100644 index 000000000..a94983e60 --- /dev/null +++ b/packages/xo-collection/collection.async.spec.js @@ -0,0 +1,132 @@ +/* eslint-env mocha */ + +import Collection, {DuplicateEntry, NoSuchEntry} from './collection' + +import eventToPromise from 'event-to-promise' +import sinon from 'sinon' + +import chai from 'chai' +const expect = chai.expect +import dirtyChai from 'dirty-chai' +chai.use(dirtyChai) + +describe('Collection', function () { + beforeEach(function (done) { + this.col = new Collection() + this.col.add('bar', 0) + + process.nextTick(done) + }) + + describe('#add()', function () { + it('adds item to the collection', function () { + const spy = sinon.spy() + this.col.on('add', spy) + + this.col.add('foo', true) + + expect(this.col.get('foo')).to.equal(true) + + // No sync events. + sinon.assert.notCalled(spy) + + // Async event. + return eventToPromise(this.col, 'add').then(function (added) { + expect(added).to.have.all.keys('foo') + expect(added.foo).to.equal(true) + }) + }) + + it('throws an exception if the item already exists', function () { + expect(() => this.col.add('bar', true)).to.throw(DuplicateEntry) + }) + }) + + describe('#update()', function () { + it('updates an item of the collection', function () { + const spy = sinon.spy() + this.col.on('update', spy) + + this.col.update('bar', 1) + expect(this.col.get('bar')).to.equal(1) // Will be forgotten by de-duplication + this.col.update('bar', 2) + expect(this.col.get('bar')).to.equal(2) + + // No sync events. + sinon.assert.notCalled(spy) + + // Async event. + return eventToPromise(this.col, 'update').then(function (updated) { + expect(updated).to.have.all.keys('bar') + expect(updated.bar).to.equal(2) + }) + }) + + it('throws an exception if the item does not exist', function () { + expect(() => this.col.update('baz', true)).to.throw(NoSuchEntry) + }) + }) + + describe('#remove()', function () { + it('removes an item of the collection', function () { + const spy = sinon.spy() + this.col.on('remove', spy) + + this.col.update('bar', 1) + expect(this.col.get('bar')).to.equal(1) // Will be forgotten by de-duplication + this.col.remove('bar') + + // No sync events. + sinon.assert.notCalled(spy) + + // Async event. + return eventToPromise(this.col, 'remove').then(function (removed) { + expect(removed).to.have.all.keys('bar') + expect(removed.bar).to.equal(null) + }) + }) + + it('throws an exception if the item does not exist', function () { + expect(() => this.col.remove('baz', true)).to.throw(NoSuchEntry) + }) + }) + + describe('#set()', function () { + it('adds item if collection has not key', function () { + const spy = sinon.spy() + this.col.on('add', spy) + + this.col.set('foo', true) + + expect(this.col.get('foo')).to.equal(true) + + // No sync events. + sinon.assert.notCalled(spy) + + // Async events. + return eventToPromise(this.col, 'add').then(function (added) { + expect(added).to.have.all.keys('foo') + expect(added.foo).to.equal(true) + }) + }) + + it('updates item if collection has key', function () { + const spy = sinon.spy() + this.col.on('udpate', spy) + + this.col.set('bar', 1) + + expect(this.col.get('bar')).to.equal(1) + + // No sync events. + sinon.assert.notCalled(spy) + + // Async events. + return eventToPromise(this.col, 'update').then(function (updated) { + expect(updated).to.have.all.keys('bar') + expect(updated.bar).to.equal(1) + }) + }) + }) + +}) diff --git a/packages/xo-collection/collection.buffer.spec.js b/packages/xo-collection/collection.buffer.spec.js deleted file mode 100644 index c12390805..000000000 --- a/packages/xo-collection/collection.buffer.spec.js +++ /dev/null @@ -1,500 +0,0 @@ -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); - - }); - - }); - - // Collection contains data 1 ================================================ - - 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(updateCount); - expect(updateSpy.calledWith(data2)).to.be.true(); - - expect(flush).to.throw(Collection.NotBuffering); - - }); - - }); - - // Collection contains data 2 (update of data 1 keys) ======================== - - describe('touch', function () { - - - it('Marks a key as buffer-updated, and gives the value for object-property modification cases', function () { - - flush = col.bufferChanges(); - - expect(updateSpy.callCount).to.eq(updateCount); - - for (var prop in data2) { - expect(col.touch(prop)).to.eq(data2[prop]); - } - - expect(updateSpy.callCount).to.eq(updateCount); - - flush(); - updateCount++; - - expect(updateSpy.callCount).to.eq(updateCount); - 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); - - }); - - }); - - // Collection is empty ======================================================= - - var dataBefore = { // Will be removed if not re-added (-> udpate) - foo: 1, - bar: 2, - baz: 3 - }; - - // Buffered from now - - var dataToAdd = { // will be out of events if not post-added (-> add) - qux: 4, - hop: 6 - }; - - var dataToUpdate = { - bar: 22, - }; - - var dataToRemove = { - hop: null, - }; - - // All above will be cleared - - var dataToPostAdd = { - baz: 33, - hip: 5 - }; - - // flush - - var expectedRemovedData = { - foo: null, - bar: null, - }; - - var expectedUpdatedData = { - baz: 33 - }; - - var expectedAddedData = { - hip:5 - }; - - describe('clear', function () { - - - it('acts as a multi-remove', function () { - - var prop; - - for (prop in dataBefore) { - expect(col.add(prop, dataBefore[prop])).to.eq(col); - } - - addCount = addSpy.callCount; // Not buffered, events have been emitted - - flush = col.bufferChanges(); - - expect(addSpy.callCount).to.eq(addCount); - expect(updateSpy.callCount).to.eq(updateCount); - expect(removeSpy.callCount).to.eq(removeCount); - - for (prop in dataToAdd) { - expect(col.add(prop, dataToAdd[prop])).to.eq(col); - } - for (prop in dataToUpdate) { - expect(col.update(prop, dataToUpdate[prop])).to.eq(col); - } - for (prop in dataToRemove) { - expect(col.remove(prop)).to.eq(col); - } - - expect(col.clear()).to.eq(col); - - for (prop in dataToPostAdd) { - expect(col.add(prop, dataToPostAdd[prop])).to.eq(col); - } - - expect(addSpy.callCount).to.eq(addCount); - expect(updateSpy.callCount).to.eq(updateCount); - expect(removeSpy.callCount).to.eq(removeCount); - - flush(); - - addCount++; - updateCount++; - removeCount++; - - expect(addSpy.callCount).to.eq(addCount); - expect(updateSpy.callCount).to.eq(updateCount); - expect(removeSpy.callCount).to.eq(removeCount); - - expect(addSpy.calledWith(expectedAddedData)).to.be.true(); - expect(updateSpy.calledWith(expectedUpdatedData)).to.be.true(); - expect(removeSpy.calledWith(expectedRemovedData)).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(|set) => add': [ - [ - {action: 'add', key: 'new', value:1}, - {action: 'set', key: 'new', value:1.5}, - {action: 'update', key: 'new', value:2}, - ], - { - add: 1, - update: 0, - remove: 0 - }, - { - add: {'new': 2} - } - ], - - 'set(1st == add) && update(|set) => add': [ - [ - {action: 'set', key: 'new', value:1}, - {action: 'update', key: 'new', value:1.5}, - {action: 'set', key: 'new', value:2}, - ], - { - add: 1, - update: 0, - remove: 0 - }, - { - add: {'new': 2} - } - ], - - 'update && update => update': [ - [ - {action: 'update', key: 'exist', value:1}, - {action: 'set', key: 'exist', value:1.5}, - {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: 'set', 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:2}, - {action: 'set', key: 'new', value:3}, - {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} - } - ], - - 'remove && set => update': [ - [ - {action: 'remove', key: 'exist'}, - {action: 'set', 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.event.spec.js b/packages/xo-collection/collection.event.spec.js deleted file mode 100644 index dc4759533..000000000 --- a/packages/xo-collection/collection.event.spec.js +++ /dev/null @@ -1,210 +0,0 @@ -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'); - -var col = new Collection.Collection(); - -describe('collection events', function () { - - // ============================================================ - - var data1 = { - 'primitive value': ['foo1', 1], - 'array value': ['bar1', [1,2]], - 'object value': ['baz1', {a:1, b:2}] - }; - - var addSpy; - var updateSpy; - var removeSpy; - - beforeEach(function () { - - addSpy = sinon.spy(); - updateSpy = sinon.spy(); - removeSpy = sinon.spy(); - col.on('add', addSpy); - col.on('update', updateSpy); - col.on('remove', removeSpy); - - }); - - afterEach(function () { - - col.removeAllListeners(); - - }); - - // Collection is empty ======================================================= - - describe('add', function () { - - leche.withData(data1, function (key, value) { - - it('Emits an add event transporting the key and value', function () { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.add(key, value)).to.eql(col); - expect(addSpy.called).to.be.true(); - expect(addSpy.calledWith({key: value})).to.be.true(); - - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - }); - - }); - - }); - - var updateData1 = { - 'primitive value': ['foo1', 3], - 'array value': ['bar1', [3,4]], - 'object value': ['baz1', {c:2, d:4}] - }; - - // Collection contains data1 ================================================= - - describe('update', function () { - - leche.withData(updateData1, function (key, value) { - - it('Emits an update event transporting the key and value', function () { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.update(key, value)).to.eql(col); - expect(updateSpy.called).to.be.true(); - expect(updateSpy.calledWith({key: value})).to.be.true(); - - expect(addSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - }); - - }); - - }); - - var data2 = { - 'primitive value': ['foo2', 1], - 'array value': ['bar2', [1,2]], - 'object value': ['baz2', {a:1, b:2}] - }; - - // Collection contains data1 updated ========================================= - - describe('set', function () { - - leche.withData(data1, function (key, value) { - - it('Emits an update event for pre-existing keys', function () { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.update(key, value)).to.eql(col); - expect(updateSpy.called).to.be.true(); - expect(updateSpy.calledWith({key: value})).to.be.true(); - - expect(addSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - }); - - }); - - // Collection contains data1 ============================================= - - leche.withData(data2, function (key, value) { - - it('Emits an add event for unexisting keys', function () { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.add(key, value)).to.eql(col); - expect(addSpy.called).to.be.true(); - expect(addSpy.calledWith({key: value})).to.be.true(); - - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - }); - - }); - - }); - - // Collection contains data1 & data 2 ======================================== - - describe('remove', function () { - - leche.withData(data1, function (key, value) { - - it('Emits an remove event transporting the key and removed value', function () { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.remove(key)).to.eql(col); - expect(removeSpy.called).to.be.true(); - expect(removeSpy.calledWith({key: value})).to.be.true(); - - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - }); - - }); - - }); - - // Collection contains data 2 ================================================ - - describe('clear', function () { - - var clearedData; - - it('Emits a remove event', function() { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.clear()).to.eq(col); - expect(removeSpy.calledOnce).to.be.true(); - - clearedData = removeSpy.lastCall.args[0]; - - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - }); - - it('Emits no event if collection is empty', function() { - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - - expect(col.clear()).to.eq(col); - - expect(addSpy.called).to.be.false(); - expect(updateSpy.called).to.be.false(); - expect(removeSpy.called).to.be.false(); - }); - - leche.withData(data2, function (key, value) { - - it('Emits a remove event with all cleared data', function () { - - expect(clearedData[key]).to.eq(value); - - }); - - }); - - }); - -}); \ No newline at end of file diff --git a/packages/xo-collection/collection.js b/packages/xo-collection/collection.js index d22981358..3532f4f99 100644 --- a/packages/xo-collection/collection.js +++ b/packages/xo-collection/collection.js @@ -1,273 +1,197 @@ -import makeError from 'make-error'; -import events from 'events'; -import _forEach from 'lodash.foreach'; - -const AlreadyBuffering = makeError('AlreadyBuffering'); -const NotBuffering = makeError('NotBuffering'); -const IllegalAdd = makeError('IllegalAdd'); -const DuplicateEntry = makeError('DuplicateEntry'); -const NoSuchEntry = makeError('NoSuchEntry'); - -class Collection extends events.EventEmitter { - - constructor () { - - super(); - - this._map = {}; - this._buffering = false; - this._size = 0; - - } - - bufferChanges () { - - if (this._buffering) { - throw new AlreadyBuffering('Already buffering'); // FIXME Really ?... - } - - this._buffering = true; - this._buffer = {}; - - return () => { - - if (!this._buffering) { - throw new NotBuffering('Nothing to flush'); // FIXME Really ? - } - - this._buffering = false; // FIXME Really ? - - let data = { - add: {data: {}}, - update: {data: {}}, - remove: {data: {}} - }; - - 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; - - }; - - } - - _touch (action, key, value) { - - if (this._buffering) { - - 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}); - - } - - } - - getId (item) { - - return item.id; - - } - - has (key) { - - return Object.hasOwnProperty.call(this._map, key); - - } - - resolveEntry (keyOrObjectWithId, valueIfKey = null) { - - let value; - let key = (undefined !== keyOrObjectWithId) ? - this.getId(keyOrObjectWithId) : - undefined; - - if (undefined === key) { - if (arguments.length < 2) { - throw new IllegalAdd(); - } else { - key = keyOrObjectWithId; - value = valueIfKey; - } - } else { - value = keyOrObjectWithId; - } - - return [key, value]; - - } - - _assertHas(key) { - - if (!this.has(key)) { - throw new NoSuchEntry(); - } - - } - - _assertHasNot(key) { - - if (this.has(key)) { - throw new DuplicateEntry(); - } - - } - - add (keyOrObjectWithId, valueIfKey = null) { - - const [key, value] = this.resolveEntry.apply(this, arguments); - - this._assertHasNot(key); - - this._map[key] = value; - - this._size++; - this._touch('add', key, value); - - return this; - - } - - set (keyOrObjectWithId, valueIfKey = null) { - - const [key, value] = this.resolveEntry.apply(this, arguments); - - const action = this.has(key) ? 'update' : 'add'; - this._map[key] = value; - if ('add' === action) { - this._size++; - } - - this._touch(action, key, value); - - return this; - - } - - get (key) { - - this._assertHas(key); - - return this._map[key]; - } - - update (keyOrObjectWithId, valueIfKey = null) { - - const [key, value] = this.resolveEntry.apply(this, arguments); - - this._assertHas(key); - - this._map[key] = value; - this._touch('update', key, value); - - return this; - - } - - touch (keyOrObjectWithId) { - - const [key] = this.resolveEntry(keyOrObjectWithId, null); - - this._assertHas(key); - - if (!this._buffering) { // FIXME Really ? - // TODO throw something - } - - this._touch('update', key); - return this.get(key); - - } - - remove (keyOrObjectWithId) { - - const [key] = this.resolveEntry(keyOrObjectWithId, null); - - this._assertHas(key); - - const oldValue = this.get(key); - 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; - - } - - clear () { - - if (this._size > 0) { // FIXME Really ? - if (!this._buffering) { - this.emit('remove', this._map); - } else { - for (let key in this._map) { - this._touch('remove', key); - } - } - } - - this._map = {}; - this._size = 0; - - return this; - - } - - get size () { - - return this._size; - - } - - get all () { - - return this._map; - - } - +import events from 'events' +import makeError from 'make-error' + +export const DuplicateEntry = makeError('DuplicateEntry') +export const BufferAlreadyFlushed = makeError('BufferAlreadyFlushed') +export const IllegalAdd = makeError('IllegalAdd') +export const NoSuchEntry = makeError('NoSuchEntry') + +export default class Collection extends events.EventEmitter { + constructor () { + super() + + this._buffering = 0 + this._map = {} + this._size = 0 + } + + bufferChanges () { + if (this._buffering++ === 0) { + this._buffer = {} + } + + let called = false + return () => { + if (called) { + throw new BufferAlreadyFlushed('Buffer flush already requested') + } + called = true + + if (--this._buffering > 0) { + return + } + + let data = { + add: {data: {}}, + remove: {data: {}}, + update: {data: {}} + } + + 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 + } + } + + _touch (action, key) { + if (this._buffering === 0) { + process.nextTick(this.bufferChanges()) + } + switch (action) { + case 'add': + this._buffer[key] = this._buffer[key] ? 'update' : 'add' + break + case 'remove': + switch (this._buffer[key]) { + case undefined: + case 'update': + this._buffer[key] = 'remove' + break + case 'add': + delete this._buffer[key] + break + } + break + case 'update': + this._buffer[key] = this._buffer[key] || 'update' + break + } + } + + getId (item) { + return item.id + } + + has (key) { + return Object.hasOwnProperty.call(this._map, key) + } + + resolveEntry (keyOrObjectWithId, valueIfKey = null) { + let value + let key = (undefined !== keyOrObjectWithId) ? + this.getId(keyOrObjectWithId) : + undefined + + if (undefined === key) { + if (arguments.length < 2) { + throw new IllegalAdd('Missing value, or object value does not provide id/key') + } else { + key = keyOrObjectWithId + value = valueIfKey + } + } else { + value = keyOrObjectWithId + } + + return [key, value] + } + + _assertHas (key) { + if (!this.has(key)) { + throw new NoSuchEntry('No ' + key + ' entry') + } + } + + _assertHasNot (key) { + if (this.has(key)) { + throw new DuplicateEntry('Attempt to duplicate ' + key + ' entry') + } + } + + add (keyOrObjectWithId, valueIfKey = null) { + const [key, value] = this.resolveEntry.apply(this, arguments) + this._assertHasNot(key) + + this._map[key] = value + this._size++ + this._touch('add', key) + + return this + } + + set (keyOrObjectWithId, valueIfKey = null) { + const [key, value] = this.resolveEntry.apply(this, arguments) + + const action = this.has(key) ? 'update' : 'add' + this._map[key] = value + if (action === 'add') { + this._size++ + } + this._touch(action, key) + + return this + } + + get (key) { + this._assertHas(key) + return this._map[key] + } + + update (keyOrObjectWithId, valueIfKey = null) { + const [key, value] = this.resolveEntry.apply(this, arguments) + this._assertHas(key) + + this._map[key] = value + this._touch('update', key) + + return this + } + + touch (keyOrObjectWithId) { + const [key] = this.resolveEntry(keyOrObjectWithId, null) + this._assertHas(key) + + this._touch('update', key) + + return this.get(key) + } + + remove (keyOrObjectWithId) { + const [key] = this.resolveEntry(keyOrObjectWithId, null) + this._assertHas(key) + + delete this._map[key] + this._size-- + this._touch('remove', key) + + return this + } + + clear () { + for (let key in this._map) { + delete this._map[key] + this._size-- + this._touch('remove', key) + } + return this + } + + get size () { + return this._size + } + + get all () { + return this._map + } } - -export default { - Collection, - AlreadyBuffering, - NotBuffering, - IllegalAdd, - DuplicateEntry, - NoSuchEntry -}; \ No newline at end of file diff --git a/packages/xo-collection/collection.spec.js b/packages/xo-collection/collection.spec.js deleted file mode 100644 index f682b2291..000000000 --- a/packages/xo-collection/collection.spec.js +++ /dev/null @@ -1,252 +0,0 @@ -var chai = require('chai'); -var expect = chai.expect; -var dirtyChai = require('dirty-chai'); -chai.use(dirtyChai); -var leche = require('leche'); - -var Collection = require('./collection'); - -var col = new Collection.Collection(); - -describe('collection', function () { - - // Collection is empty ======================================================= - - var data1 = { - 'primitive value': ['foo1', 1], - 'array value': ['bar1', [1,2]], - 'object value': ['baz1', {a:1, b:2}] - }; - - describe('add', function () { - - leche.withData(data1, function (key, value) { - - it('Adds a new entry to the collection', function () { - expect(col.add(key, value)).to.eql(col); - }); - - }); - - leche.withData(data1, function (key, value) { - - it('We cannot add on a pre-existing key', function () { - expect(function () { - col.add(key, value); - }).to.throw(Collection.DuplicateEntry); - }); - - }); - - }); - - // Collection contains data 1 ================================================ - - var data2 = { - 'primitive value': ['foo2', 1], - 'array value': ['bar2', [1,2]], - 'object value': ['baz2', {a:1, b:2}] - }; - - describe('set', function () { - - leche.withData(data2, function (key, value) { - - it('Sets an entry of the collection...', function () { - expect(col.set(key, value)).to.eql(col); - }); - - }); - - leche.withData(data2, function (key, value) { - - it('...would it already exists or not', function () { - expect(col.set(key, value)).to.eql(col); - }); - - }); - - }); - - // Collection contains data 1 & 2 ============================================ - - var unexistingData = { - 'Unexisting key/entry': ['wat', 'any'] - }; - - describe('get', function () { - - leche.withData(data1, function (key, value) { - - it('Returns the value of an entry of the collection...', function () { - expect(col.get(key)).to.eql(value); - }); - - }); - - leche.withData(data2, function (key, value) { - - it('Returns the value of an entry of the collection...', function () { - expect(col.get(key)).to.eql(value); - }); - - }); - - leche.withData(unexistingData, function (key) { - - it('...or throws if it does not exist', function () { - expect(function () { - col.get(key); - }).to.throw(Collection.NoSuchEntry); - }); - - }); - - }); - - // Collection contains data 1 & 2 ============================================ - - var updateData2 = { - 'primitive value': ['foo2', 3], - 'array value': ['bar2', [3,4]], - 'object value': ['baz2', {c:3, d:4}] - }; - - describe('update', function () { - - leche.withData(updateData2, function (key, value) { - - it('updates the given entries...', function () { - expect(col.update(key, value)).to.eql(col); - }); - - }); - - leche.withData(updateData2, function (key, value) { - - it('...so we can see the values we get have changed accordingly', function () { - expect(col.get(key)).to.eql(value); - }); - - }); - - leche.withData(unexistingData, function (key, value) { - - it('If the entry does not exist, updating throws', function () { - expect(function () { - col.update(key, value); - }).to.throw(Collection.NoSuchEntry); - }); - - }); - - }); - - // Collection contains data 1 & 2 updated ==================================== - - describe('remove', function () { - - leche.withData(data2, function (key) { - - it('removes the given entries...', function () { - expect(col.remove(key)).to.eql(col); - }); - - }); - - leche.withData(data2, function (key) { - - it('...so trying to get them again throws...', function () { - expect(function () { - col.get(key); - }).to.throw(Collection.NoSuchEntry); - }); - - }); - - leche.withData(data2, function (key) { - - it('...and trying to remove them again also throws', function () { - expect(function () { - col.remove(key); - }).to.throw(Collection.NoSuchEntry); - }); - - }); - - }); - - // Collection contains data 1 ================================================ - - describe('has', function () { - - leche.withData(data1, function (key) { - - it('Tells us if an entry exists...', function () { - expect(col.has(key)).to.be.true(); - }); - - }); - - leche.withData(data2, function (key) { - - it('...or not', function () { - expect(col.has(key)).to.be.false(); - }); - - }); - - }); - - // Collection contains data 1 ================================================ - - describe('size', function () { - - it('Reveals the number of existing entries', function () { - expect(col.size).to.eq(Object.keys(data1).length); - }); - - }); - - // Collection contains data 1 ================================================ - - describe('all', function () { - - leche.withData(data1, function (key, value) { - - it('Gives access to the internal collection...', function () { - expect(col.all).to.have.ownProperty(key); - expect(col.all[key]).to.eq(value); - }); - - }); - - leche.withData(data2, function (key) { - - it('Gives access to the internal collection...', function () { - expect(col.all).to.not.have.ownProperty(key); - expect(col.all[key]).to.eq(undefined); - }); - - }); - - it('Gives access to the internal collection...', function () { - expect(col.size).to.eq(Object.keys(col.all).length); - }); - - }); - - // Collection contains data 1 ================================================ - - describe('clear', function () { - - it('wipes out all the collection', function () { - expect(col.clear()).to.eq(col); - - expect(col.size).to.eq(0); - expect(col.all).to.eql({}); - }); - - }); - -}); \ No newline at end of file diff --git a/packages/xo-collection/package.json b/packages/xo-collection/package.json index 22a0312ab..e5a0525bd 100644 --- a/packages/xo-collection/package.json +++ b/packages/xo-collection/package.json @@ -11,6 +11,7 @@ "babel": "^4.7.16", "chai": "^2.2.0", "dirty-chai": "^1.2.0", + "event-to-promise": "^0.3.2", "leche": "^2.1.1", "mocha": "^2.2.1", "sinon": "^1.14.1"