Implementation seems ok. Deeper tests to come
This commit is contained in:
parent
35c64be3d7
commit
cbd93f450e
132
packages/xo-collection/collection.async.spec.js
Normal file
132
packages/xo-collection/collection.async.spec.js
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
@ -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();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -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);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -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
|
||||
};
|
@ -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({});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user