498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
import { expect } from 'chai';
|
|
import { it } from 'mocha';
|
|
import sinon from 'sinon';
|
|
|
|
import { memoize } from '../../../src/program/utils/memoize.js';
|
|
|
|
describe('Memoize', () => {
|
|
// This is important, since memoization only works when cache is disabled.
|
|
// We want to prevent that another test unintentionally disabled caching.
|
|
memoize.restoreCaching();
|
|
|
|
describe('With primitives', () => {
|
|
describe('Numbers', () => {
|
|
it(`returns cached result when called with same parameters`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized(1, 3)).to.equal(4);
|
|
expect(sumCalled).to.equal(2);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized(1, 3)).to.equal(4);
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
it(`returns cached result per function for same args`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
let sum2Called = 0;
|
|
function sum2(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sum2Called += 1;
|
|
return a + b;
|
|
}
|
|
const sum2Memoized = memoize(sum2);
|
|
|
|
expect(sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(0);
|
|
|
|
expect(sum2Memoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
|
|
// Both cached
|
|
expect(sumMemoized(1, 2)).to.equal(3);
|
|
expect(sum2Memoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
});
|
|
});
|
|
describe('Strings', () => {
|
|
it(`returns cached result when called with same parameters`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {string} a */ a, /** @type {string} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized('1', '3')).to.equal('13');
|
|
expect(sumCalled).to.equal(2);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized('1', '3')).to.equal('13');
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
it(`returns cached result per function for same args`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {string} a */ a, /** @type {string} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
let sum2Called = 0;
|
|
function sum2(/** @type {string} a */ a, /** @type {string} a */ b) {
|
|
sum2Called += 1;
|
|
return a + b;
|
|
}
|
|
const sum2Memoized = memoize(sum2);
|
|
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(0);
|
|
|
|
expect(sum2Memoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
|
|
// Both cached
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sum2Memoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('With non primitives', () => {
|
|
describe('Arrays', () => {
|
|
it(`returns cached result when called with same parameters`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
|
|
sumCalled += 1;
|
|
return [...a, ...b];
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized([1], [3])).to.deep.equal([1, 3]);
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
it(`returns cached result per function for same args`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
|
|
sumCalled += 1;
|
|
return [...a, ...b];
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
let sum2Called = 0;
|
|
function sum2(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
|
|
sum2Called += 1;
|
|
return [...a, ...b];
|
|
}
|
|
const sum2Memoized = memoize(sum2);
|
|
|
|
expect(sumMemoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(0);
|
|
|
|
expect(sum2Memoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
|
|
// Both cached
|
|
expect(sumMemoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sum2Memoized([1], [2])).to.deep.equal([1, 2]);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe('Objects', () => {
|
|
it(`returns cached result when called with same parameters`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
|
|
sumCalled += 1;
|
|
return { ...a, ...b };
|
|
}
|
|
const sumMemoized = memoize(sum, { serializeObjects: true });
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized({ x: 1 }, { y: 3 })).to.deep.equal({ x: 1, y: 3 });
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
it(`returns cached result per function for same args`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
|
|
sumCalled += 1;
|
|
return { ...a, ...b };
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
let sum2Called = 0;
|
|
function sum2(/** @type {object} a */ a, /** @type {object} a */ b) {
|
|
sum2Called += 1;
|
|
return { ...a, ...b };
|
|
}
|
|
const sum2Memoized = memoize(sum2);
|
|
|
|
expect(sumMemoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(0);
|
|
|
|
expect(sum2Memoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
|
|
// Both cached
|
|
expect(sumMemoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sum2Memoized({ x: 1 }, { y: 2 })).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe('When non primitives (references) are returned', () => {
|
|
// Solve this by making sure your memoized function uses Object.freeze
|
|
it(`will be affected by edited non primitive returns`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
|
|
sumCalled += 1;
|
|
return { ...a, ...b };
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
const result = sumMemoized({ x: 1 }, { y: 2 });
|
|
expect(result).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
const resultCached = sumMemoized({ x: 1 }, { y: 2 });
|
|
expect(resultCached).to.equal(result);
|
|
expect(resultCached).to.deep.equal({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Outside world can edit returned reference
|
|
// @ts-expect-error
|
|
resultCached.x = 3;
|
|
// Return from cache
|
|
const lastResult = sumMemoized({ x: 1 }, { y: 2 });
|
|
expect(lastResult).to.equal(result);
|
|
expect(lastResult).to.not.eql({ x: 1, y: 2 });
|
|
expect(sumCalled).to.equal(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Asynchronous', () => {
|
|
it(`returns cached result when called with same parameters`, async () => {
|
|
let sumCalled = 0;
|
|
async function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
expect(await sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(await sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Put in cache for args combination
|
|
expect(await sumMemoized(1, 3)).to.equal(4);
|
|
expect(sumCalled).to.equal(2);
|
|
|
|
// Return from cache
|
|
expect(await sumMemoized(1, 3)).to.equal(4);
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
it(`returns cached result per function for same args`, async () => {
|
|
let sumCalled = 0;
|
|
async function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
let sum2Called = 0;
|
|
async function sum2(/** @type {number} a */ a, /** @type {number} a */ b) {
|
|
sum2Called += 1;
|
|
return a + b;
|
|
}
|
|
const sum2Memoized = memoize(sum2);
|
|
|
|
expect(await sumMemoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(0);
|
|
|
|
expect(await sum2Memoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
|
|
// Both cached
|
|
expect(await sumMemoized(1, 2)).to.equal(3);
|
|
expect(await sum2Memoized(1, 2)).to.equal(3);
|
|
expect(sumCalled).to.equal(1);
|
|
expect(sum2Called).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe('Cache', () => {
|
|
it(`"memoizedFn.clearCache()" clears the cache for a memoized fn"`, async () => {
|
|
let sumCalled = 0;
|
|
function sum(/** @type {string} a */ a, /** @type {string} a */ b) {
|
|
sumCalled += 1;
|
|
return a + b;
|
|
}
|
|
const sumMemoized = memoize(sum);
|
|
|
|
// Put in cache for args combination
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
// Return from cache
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(1);
|
|
|
|
sumMemoized.clearCache();
|
|
|
|
// Now the original function is called again
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(2);
|
|
|
|
// Return from new cache again
|
|
expect(sumMemoized('1', '2')).to.equal('12');
|
|
expect(sumCalled).to.equal(2);
|
|
});
|
|
|
|
describe('Strategies', () => {
|
|
beforeEach(() => {
|
|
memoize.restoreCaching();
|
|
});
|
|
|
|
describe('lfu (least frequently used) strategy', () => {
|
|
it('has lfu strategy by default', async () => {
|
|
expect(memoize.cacheStrategy).to.equal('lfu');
|
|
});
|
|
|
|
it('removes least used from cache', async () => {
|
|
memoize.limitForCacheStrategy = 2;
|
|
|
|
const spy1 = sinon.spy(() => {});
|
|
const spy2 = sinon.spy(() => {});
|
|
const spy3 = sinon.spy(() => {});
|
|
|
|
const spy1Memoized = memoize(spy1);
|
|
const spy2Memoized = memoize(spy2);
|
|
const spy3Memoized = memoize(spy3);
|
|
|
|
// Call spy1 3 times
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 1 }]);
|
|
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 2 }]);
|
|
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 3 }]);
|
|
|
|
// Call spy2 2 times (so it's the least frequently used)
|
|
spy2Memoized();
|
|
expect(spy2.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 },
|
|
{ fn: spy2Memoized, count: 1 },
|
|
]);
|
|
|
|
spy2Memoized();
|
|
expect(spy2.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 },
|
|
{ fn: spy2Memoized, count: 2 },
|
|
]);
|
|
|
|
// When we add number 3, we exceed limitForCacheStrategy
|
|
// This means that we 'free' the least frequently used (spy2)
|
|
spy3Memoized();
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 },
|
|
{ fn: spy3Memoized, count: 1 },
|
|
]);
|
|
|
|
spy2Memoized();
|
|
expect(spy2.callCount).to.equal(2);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 },
|
|
{ fn: spy2Memoized, count: 1 }, // we start over
|
|
]);
|
|
|
|
spy2Memoized(); // 2
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 }, // we start over
|
|
{ fn: spy2Memoized, count: 2 },
|
|
]);
|
|
spy2Memoized(); // 3
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 }, // we start over
|
|
{ fn: spy2Memoized, count: 3 },
|
|
]);
|
|
spy2Memoized(); // 4
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 3 }, // we start over
|
|
{ fn: spy2Memoized, count: 4 },
|
|
]);
|
|
|
|
console.debug('spy3Memoized');
|
|
spy3Memoized();
|
|
console.debug(memoize.cacheStrategyItems);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy2Memoized, count: 4 },
|
|
{ fn: spy3Memoized, count: 1 }, // we start over
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('lru (least recently used) strategy', () => {
|
|
it(`can set lru strategy"`, async () => {
|
|
memoize.cacheStrategy = 'lru';
|
|
expect(memoize.cacheStrategy).to.equal('lru');
|
|
});
|
|
|
|
it('removes least recently used from cache', async () => {
|
|
memoize.limitForCacheStrategy = 2;
|
|
memoize.cacheStrategy = 'lru';
|
|
|
|
const spy1 = sinon.spy(() => {});
|
|
const spy2 = sinon.spy(() => {});
|
|
const spy3 = sinon.spy(() => {});
|
|
|
|
const spy1Memoized = memoize(spy1);
|
|
const spy2Memoized = memoize(spy2);
|
|
const spy3Memoized = memoize(spy3);
|
|
|
|
// Call spy1 3 times
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 1 }]);
|
|
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 2 }]);
|
|
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 3 }]);
|
|
|
|
spy2Memoized();
|
|
expect(spy2.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy2Memoized, count: 1 },
|
|
{ fn: spy1Memoized, count: 3 },
|
|
]);
|
|
|
|
spy2Memoized();
|
|
expect(spy2.callCount).to.equal(1);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy2Memoized, count: 2 },
|
|
{ fn: spy1Memoized, count: 3 },
|
|
]);
|
|
|
|
spy3Memoized();
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy3Memoized, count: 1 },
|
|
{ fn: spy2Memoized, count: 2 },
|
|
]);
|
|
|
|
spy1Memoized();
|
|
expect(spy1.callCount).to.equal(2);
|
|
expect(memoize.cacheStrategyItems).to.deep.equal([
|
|
{ fn: spy1Memoized, count: 1 }, // we start over
|
|
{ fn: spy3Memoized, count: 1 },
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|