diff --git a/app/assets/javascripts/lib/utils/recurrence.js b/app/assets/javascripts/lib/utils/recurrence.js new file mode 100644 index 0000000000000000000000000000000000000000..b9afb939090b06961a74e3f1aeae79380cf21b02 --- /dev/null +++ b/app/assets/javascripts/lib/utils/recurrence.js @@ -0,0 +1,154 @@ +import { uuids } from '../../diffs/utils/uuids'; + +/** + * @module recurrence + */ + +const instances = {}; + +/** + * Create a new unique {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance} + */ +export function create() { + const id = uuids()[0]; + let handlers = {}; + let count = 0; + + /** + * @namespace RecurInstance + * @description A RecurInstance tracks the count of any occurrence as registered by calls to occur. + *

+ * It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user. + *

+ * While a RecurInstance isn't specific to any particular use-case, it may be useful for: + *
+ * + * @summary A closure to track repeated occurrences of any arbitrary event. + * */ + const instance = { + /** + * @type {module:uuids~UUIDv4} + * @description A randomly generated {@link module:uuids~UUIDv4|UUID} for this particular recurrence instance + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get id() { + return id; + }, + /** + * @type {Number} + * @description The number of times this particular instance of recurrence has been triggered + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get count() { + return count; + }, + /** + * @type {Object} + * @description The handlers assigned to this recurrence tracker + * @example + * myRecurrence.handle( 4, () => console.log( "four" ) ); + * console.log( myRecurrence.handlers ); // {"4": () => console.log( "four" )} + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get handlers() { + return handlers; + }, + /** + * @type {Boolean} + * @description Delete any internal reference to the instance. + *
+ * Keep in mind that this will only attempt to remove the internal reference. + *
+ * If your code maintains a reference to the instance, the regular garbage collector will not free the memory. + * @memberof module:recurrence~RecurInstance + * @inner + */ + free() { + return delete instances[id]; + }, + /** + * @description Register a handler to be called when this occurrence is seen onCount number of times. + * @param {Number} onCount - The number of times the occurrence has been seen to respond to + * @param {Function} behavior - A callback function to run when the occurrence has been seen onCount times + * @memberof module:recurrence~RecurInstance + * @inner + */ + handle(onCount, behavior) { + if (onCount && behavior) { + handlers[onCount] = behavior; + } + }, + /** + * @description Remove the behavior callback handler that would be run when the occurrence is seen onCount times + * @param {Number} onCount - The count identifier for which to eject the callback handler + * @memberof module:recurrence~RecurInstance + * @inner + */ + eject(onCount) { + if (onCount) { + delete handlers[onCount]; + } + }, + /** + * @description Register that this occurrence has been seen and trigger any appropriate handlers + * @memberof module:recurrence~RecurInstance + * @inner + */ + occur() { + count += 1; + + if (typeof handlers[count] === 'function') { + handlers[count](count); + } + }, + /** + * @description Reset this recurrence instance without destroying it entirely + * @param {Object} [options] + * @param {Boolean} [options.currentCount = true] - Whether to reset the count + * @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state + * @memberof module:recurrence~RecurInstance + * @inner + */ + reset({ currentCount = true, handlersList = false } = {}) { + if (currentCount) { + count = 0; + } + + if (handlersList) { + handlers = {}; + } + }, + }; + + instances[id] = instance; + + return instance; +} + +/** + * Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID} + * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance + */ +export function recall(id) { + return instances[id]; +} + +/** + * Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID} + * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released + */ +export function free(id) { + return recall(id)?.free() || false; +} diff --git a/spec/frontend/lib/utils/recurrence_spec.js b/spec/frontend/lib/utils/recurrence_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fc22529dffc9778ab4ff99c5e8bd1a0fb5959278 --- /dev/null +++ b/spec/frontend/lib/utils/recurrence_spec.js @@ -0,0 +1,333 @@ +import { create, free, recall } from '~/lib/utils/recurrence'; + +const HEX = /[a-f0-9]/i; +const HEX_RE = HEX.source; +const UUIDV4 = new RegExp( + `${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`, + 'i', +); + +describe('recurrence', () => { + let recurInstance; + let id; + + beforeEach(() => { + recurInstance = create(); + id = recurInstance.id; + }); + + afterEach(() => { + id = null; + recurInstance.free(); + }); + + describe('create', () => { + it('returns an object with the correct external api', () => { + expect(recurInstance).toMatchObject( + expect.objectContaining({ + id: expect.stringMatching(UUIDV4), + count: 0, + handlers: {}, + free: expect.any(Function), + handle: expect.any(Function), + eject: expect.any(Function), + occur: expect.any(Function), + reset: expect.any(Function), + }), + ); + }); + }); + + describe('recall', () => { + it('returns a previously created RecurInstance', () => { + expect(recall(id).id).toBe(id); + }); + + it("returns undefined if the provided UUID doesn't refer to a stored RecurInstance", () => { + expect(recall('1234')).toBeUndefined(); + }); + }); + + describe('free', () => { + it('returns true when the RecurInstance exists', () => { + expect(free(id)).toBe(true); + }); + + it("returns false when the ID doesn't refer to a known RecurInstance", () => { + expect(free('1234')).toBe(false); + }); + + it('removes the correct RecurInstance from the list of references', () => { + const anotherInstance = create(); + + expect(recall(id)).toEqual(recurInstance); + expect(recall(anotherInstance.id)).toEqual(anotherInstance); + + free(id); + + expect(recall(id)).toBeUndefined(); + expect(recall(anotherInstance.id)).toEqual(anotherInstance); + + anotherInstance.free(); + }); + }); + + describe('RecurInstance (`create()` return value)', () => { + it.each` + property | value | alias + ${'id'} | ${expect.stringMatching(UUIDV4)} | ${'[a string matching the UUIDv4 specification]'} + ${'count'} | ${0} | ${0} + ${'handlers'} | ${{}} | ${{}} + `( + 'has the correct primitive value $alias for the member `$property` to start', + ({ property, value }) => { + expect(recurInstance[property]).toEqual(value); + }, + ); + + describe('id', () => { + it('cannot be changed manually', () => { + expect(() => { + recurInstance.id = 'new-id'; + }).toThrow(TypeError); + + expect(recurInstance.id).toBe(id); + }); + + it.each` + method + ${'free'} + ${'handle'} + ${'eject'} + ${'occur'} + ${'reset'} + `('does not change across any method call - like after `$method`', ({ method }) => { + recurInstance[method](); + + expect(recurInstance.id).toBe(id); + }); + }); + + describe('count', () => { + it('cannot be changed manually', () => { + expect(() => { + recurInstance.count = 9999; + }).toThrow(TypeError); + + expect(recurInstance.count).toBe(0); + }); + + it.each` + method + ${'free'} + ${'handle'} + ${'eject'} + ${'reset'} + `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => { + recurInstance[method](); + + expect(recurInstance.count).toBe(0); + }); + + it('increments by one each time `.occur()` is called', () => { + expect(recurInstance.count).toBe(0); + recurInstance.occur(); + expect(recurInstance.count).toBe(1); + recurInstance.occur(); + expect(recurInstance.count).toBe(2); + }); + }); + + describe('handlers', () => { + it('cannot be changed manually', () => { + const fn = jest.fn(); + + recurInstance.handle(1, fn); + expect(() => { + recurInstance.handlers = {}; + }).toThrow(TypeError); + + expect(recurInstance.handlers).toStrictEqual({ + 1: fn, + }); + }); + + it.each` + method + ${'free'} + ${'occur'} + ${'eject'} + ${'reset'} + `("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => { + recurInstance[method](); + + expect(recurInstance.handlers).toEqual({}); + }); + + it('adds handlers to the correct slots', () => { + const fn1 = jest.fn(); + const fn2 = jest.fn(); + + recurInstance.handle(100, fn1); + recurInstance.handle(1000, fn2); + + expect(recurInstance.handlers).toMatchObject({ + 100: fn1, + 1000: fn2, + }); + }); + }); + + describe('free', () => { + it('removes itself from recallable memory', () => { + expect(recall(id)).toEqual(recurInstance); + + recurInstance.free(); + + expect(recall(id)).toBeUndefined(); + }); + }); + + describe('handle', () => { + it('adds a handler for the provided count', () => { + const fn = jest.fn(); + + recurInstance.handle(5, fn); + + expect(recurInstance.handlers[5]).toEqual(fn); + }); + + it("doesn't add any handlers if either the count or behavior aren't provided", () => { + const fn = jest.fn(); + + recurInstance.handle(null, fn); + // Note that it's not possible to react to something not happening (without timers) + recurInstance.handle(0, fn); + recurInstance.handle(5, null); + + expect(recurInstance.handlers).toEqual({}); + }); + }); + + describe('eject', () => { + it('removes the handler assigned to the particular count slot', () => { + recurInstance.handle(1, jest.fn()); + + expect(recurInstance.handlers[1]).toBeTruthy(); + + recurInstance.eject(1); + + expect(recurInstance.handlers).toEqual({}); + }); + + it("succeeds (or fails gracefully) when the count provided doesn't have a handler assigned", () => { + recurInstance.eject('abc'); + recurInstance.eject(1); + + expect(recurInstance.handlers).toEqual({}); + }); + + it('makes no changes if no count is provided', () => { + const fn = jest.fn(); + + recurInstance.handle(1, fn); + + recurInstance.eject(); + + expect(recurInstance.handlers[1]).toStrictEqual(fn); + }); + }); + + describe('occur', () => { + it('increments the .count property by 1', () => { + expect(recurInstance.count).toBe(0); + + recurInstance.occur(); + + expect(recurInstance.count).toBe(1); + }); + + it('calls the appropriate handlers', () => { + const fn1 = jest.fn(); + const fn5 = jest.fn(); + const fn10 = jest.fn(); + + recurInstance.handle(1, fn1); + recurInstance.handle(5, fn5); + recurInstance.handle(10, fn10); + + expect(fn1).not.toHaveBeenCalled(); + expect(fn5).not.toHaveBeenCalled(); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).not.toHaveBeenCalled(); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).toHaveBeenCalledTimes(1); + expect(fn10).not.toHaveBeenCalled(); + + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + recurInstance.occur(); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn5).toHaveBeenCalledTimes(1); + expect(fn10).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset', () => { + it('resets the count only, by default', () => { + const fn = jest.fn(); + + recurInstance.handle(3, fn); + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset(); + + expect(recurInstance.count).toBe(0); + expect(recurInstance.handlers).toEqual({ 3: fn }); + }); + + it('also resets the handlers, by specific request', () => { + const fn = jest.fn(); + + recurInstance.handle(3, fn); + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset({ handlersList: true }); + + expect(recurInstance.count).toBe(0); + expect(recurInstance.handlers).toEqual({}); + }); + + it('leaves the count in place, by request', () => { + recurInstance.occur(); + recurInstance.occur(); + + expect(recurInstance.count).toBe(2); + + recurInstance.reset({ currentCount: false }); + + expect(recurInstance.count).toBe(2); + }); + }); + }); +});